API guide¶
The public surface of lcs_parcels is two sibling families, a
SeedGrid family and a FlowMap family, each with a shared abstract base and two
concrete stencils, exported from the package root:
from lcs_parcels import SeedGrid, NeighborSeedGrid, AuxiliarySeedGrid, FlowMap, NeighborFlowMap, AuxiliaryFlowMap
ftle_ridge_seeds, shrink_lines and prune_shrink_lines turn a FlowMap’s
strain field into hyperbolic-LCS curves (see
Hyperbolic LCS: shrink lines below), and
FlowMap.hyperbolic_lcs() runs them in one call.
Every adjacent same-typed argument pair on the public surface, meaning every
lon/lat pair, is keyword-only, so a transposed call raises TypeError.
A SeedGrid lays out reference positions and emits a particle set for Parcels; the
advected positions are ingested back into a FlowMap, which computes the
deformation gradient \(\nabla F\) and everything downstream (Cauchy–Green \(C\), its
eigen-decomposition, and the FTLE). The two families are siblings: neither
class is a subclass of the other, so a SeedGrid has no diagnostics and a FlowMap
emits no particle set.
Symbols and units are defined in notation.md. The type
structure and the session walkthrough are in
architecture.md. The local east/north frame that
separations are measured in, and the tuning parameters, are in
numerics.md. Naming follows
Haller (2015),
doi:10.1146/annurev-fluid-010313-141322.
The package contains no Parcels code: a SeedGrid emits particle sets and
ingests advected positions; Parcels (external) owns the integration.
Data model¶
Each object wraps an xr.Dataset, held in .ds; these are not xr.Dataset
subclasses, so xarray calls go through .ds. Logical grid dims are i, j. A
SeedGrid is all-coordinates (no data variables) and carries no time: it holds the
diagnostic grid points and the reference release positions \(x_0\). A FlowMap
adds the advected positions as its only data variables, plus scalar t0/T
coordinates.
Name |
Role |
Dims |
Kind |
On |
|---|---|---|---|---|
|
diagnostic grid points (every diagnostic is reported here) |
|
coords |
seed + flow map, both stencils |
|
reference release positions \(x_0\) |
|
coords |
seed + flow map |
|
advected positions, the flow map \(F_{t_0}^{t_1}(x_0)\) |
same dims as |
data vars |
flow map only |
|
auxiliary stencil arm, |
|
coord |
|
|
release time |
scalar |
coord |
flow map only |
|
signed integration window \(T = t_1 - t_0\) ( |
scalar |
coord |
flow map only |
lon_grid/lat_grid have the same meaning on both stencils: they label every
diagnostic and are what a gridded plot is drawn against. For the Neighbor*
classes the release position is the grid point, so lon_grid equals lon_0
there, and both pairs are present. For the Auxiliary* classes the release
positions are the four stencil arms, so lon_0/lat_0 (and the advected
lon/lat on a flow map) carry the extra displacement dim and hold the arm
positions explicitly.
A single FlowMap carries t0/T as scalar coords; t1 is not stored,
being recoverable as t0 + T.
Longitudes are stored in whatever convention they arrive in (\([-180, 180)\), \([0, 360)\), or anything else) and are never renormalised, so what comes back sits on the branch that went in. Only longitude differences and means are wrapped, so a stencil straddling the antimeridian differences correctly whatever branch its points sit on.
The lon_grid axis itself must be monotonic, which is a stricter
requirement: FlowMap.image and shrink_lines interpolate along it. Seed a
domain crossing the antimeridian on 170, 175, 180, 185, not on
170, 175, 180, -175. On the latter, hyperbolic_lcs() raises ValueError
(“the points in dimension 0 must be strictly ascending or descending”) out of
SciPy, and image() does not raise at all. It reads the axis as if it were
sorted, so a point in the wrapped half of the domain comes back NaN or
interpolated between the wrong two grid points.
Both families expose the diagnostic grid directly, so no consumer indexes .ds
for it:
SeedGrid.lon_grid -> xr.DataArray # property, (i, j), degrees east
SeedGrid.lat_grid -> xr.DataArray # property, (i, j), degrees north
FlowMap.lon_grid -> xr.DataArray # same, on the flow map
FlowMap.lat_grid -> xr.DataArray
FlowMap.grid_image -> xr.Dataset # abstract property (per-stencil)
grid_imageis the flow map image of the diagnostic grid points, \(F_{t_0}^{t_1}(x_{\mathrm{grid}})\):lon/lat(degrees) on(i, j), one advected position per grid point whatever stencil it was released with. ForNeighborFlowMapthat is the advected positions unchanged; forAuxiliaryFlowMapit is the centroid of the four advected arms, with the longitude averaged on the circle so four arms straddling the antimeridian average to a position between them, taken withskipna=Falseso a single lost arm makes the whole grid pointNaN.
Both SeedGrid and FlowMap have a terse repr, one line for the seed grid and
two for the flow map; display .ds to see the dataset itself.
>>> seed
<NeighborSeedGrid 6x5 grid, lon -25.00..-20.00, lat 15.00..20.00>
>>> flowmap
<NeighborFlowMap 6x5 grid, lon -25.00..-20.00, lat 15.00..20.00,
t0 2020-01-01T00:00:00, T +7.0 days>
Constructors and round-trip¶
A seed is built, emitted, ingested into a flow map, and (optionally) collapsed back to a seed. Two calls cross between the families, and neither object holds a reference to the other.
SeedGrid.from_axes(*, lon, lat) -> Self # classmethod (abstract)
SeedGrid.to_parcels_pset() -> tuple[list, list] # concrete (base)
SeedGrid.pset_to_flowmap(*, lon, lat, t0, t1) -> FlowMap # concrete (base)
FlowMap.to_seed() -> SeedGrid # concrete (base)
from_axes(*, lon, lat)builds a seed grid from 1-D lon/lat axes, broadcast into 2-D fields on(i, j)(lon varying alongi, lat alongj) and stored as the diagnostic gridlon_grid/lat_grid. No time is recorded:t0and the windowTenter only atpset_to_flowmap.NeighborSeedGrid.from_axesstores those same points as the reference positionslon_0/lat_0.AuxiliarySeedGrid.from_axesalso takes a keyword-onlyaux_separation_m(the controlled arm separation \(s\) in metres; default1000.0), lays out the fixed four-armdisplacement = ['east', 'north', 'west', 'south']stencil at \(\pm s\) about each grid point, in that point’s own local east/north frame so the east–west and north–south arm spans are \(2s\) at every latitude, and stores those arms explicitly aslon_0/lat_0on(i, j, displacement). It raisesValueErrorif \(s\) would span 90 degrees of longitude or more at any grid point, which happens closer to a pole than about \(0.64\,s\): 640 m for the default \(s = 1\) km, 32 km for \(s = 50\) km. The longitude increment an eastward offset of \(s\) metres needs grows without bound towards the pole, so the east–west arms are rejected rather than approximated.to_parcels_pset()flattens the reference release positions to plain(lon, lat)lists (a 2-tuple) over theparticleindex (('i', 'j'), plus'displacement'forAuxiliarySeedGrid). The auxiliary arms are emitted directly from the explicitlon_0/lat_0. The 2-tuple is unpacked before it is fed back in, sincepset_to_flowmapis keyword-only:lon_0, lat_0 = seed.to_parcels_pset() # ... advect (lon_0, lat_0) with Parcels, collect (lon1, lat1) ... flowmap = seed.pset_to_flowmap(lon=lon1, lat=lat1, t0=t0, t1=t1)
pset_to_flowmap(*, lon, lat, t0, t1)reattaches the flat advected positions onto the seed’sparticleindex as the advectedlon/lat, leaving the referencelon_0/lat_0(and any auxiliary geometry) untouched, and produce the paired concreteFlowMap. It records the release timet0and the derived signed windowT = t1 - t0as scalar coordinates (t1itself is not stored). Lost particles arrive asNaNand propagate through the diagnostics asNaN. Backward integration is selected by passing at1beforet0(negativeT); no separate direction flag exists. A zero window (t1 == t0) is rejected withValueError, since the FTLE’s \(1/|T|\) would divide by zero.to_seed()drops the advectedlon/latand the scalart0/Tcoords, recovering the pairedSeedGrid; the lossless inverse ofpset_to_flowmap. Re-emitting reproduces the same flat particle set. ForAuxiliary*this rebuilds from the carried arms, needing neither the original axes noraux_separation_m.
The reusable-template workflow (same grid, sweep t1, or re-release at a new
t0) is therefore flowmap.to_seed() then
seed.pset_to_flowmap(lon=..., lat=..., t0=..., t1=...), with no shared state:
the seed is a spatial template and every release passes its own (t0, t1).
Operators¶
The diagnostics live on FlowMap; a SeedGrid has none (it carries no
advected positions or window).
FlowMap.deformation_gradient() -> xr.DataArray # abstract (per-stencil)
FlowMap.cauchy_green() -> xr.DataArray # concrete (base)
FlowMap.cg_eigen() -> xr.Dataset # concrete (base)
FlowMap.ftle() -> xr.DataArray # concrete (base)
deformation_gradient()returns \(\nabla F = \partial(\text{lon}, \text{lat}) / \partial(\text{lon}_0, \text{lat}_0)\) as advected separations (numerator, from the ingested outputs) over reference separations (denominator). Each separation is taken in metres in the local east/north frame of the two points it connects (seenotation.md). The accuracy of a separation is limited by the span of the stencil, not by the size or position of the domain; the error series is innumerics.md. Dimsi,j,row,col, a set rather than an order. The package is label-based, so the axis order the call returns is not part of the contract (today it is('row', 'col', 'i', 'j'), and that may change).row/colare dimension coordinates valued['x', 'y']andgradF.sel(row=a, col=b) = dF_a / dx0_b. There is nocompcoord on the tensor. The stencil is per-subclass:NeighborFlowMap: central difference against neighbours(i +/- 1, j +/- 1); domain-edge cells areNaNby construction.AuxiliaryFlowMap: per-point four-arm central difference (east-west, north-south over2s); defined at every grid point, noNaNedges.
cauchy_green()returns \(C = (\nabla F)^\top \nabla F\), symmetric, on the same dim seti,j,row,col(again in no contractual order, though as shipped it is('row', 'i', 'j', 'col')). Select by label, and.transpose()yourself if you need a particular layout.cg_eigen()is the eigen-decomposition of \(C\) vianp.linalg.eigh. Returns aDatasetwithlambdaon(i, j, eig)(eigenvalues ascending, \(0 < \lambda_1 \le \lambda_2\),eig = [0, 1]) andxion(i, j, comp, eig)(orthonormal eigenvectors,comp = ['x', 'y']).ftle()returns \(\Lambda = \tfrac{1}{|T|}\log\sqrt{\lambda_{\max}}\) using the largest eigenvalue \(\lambda_2\) and the recorded signed windowT. Dims(i, j), units 1/second. Only \(|T|\) enters, so backward and forward integration of the same map give the same FTLE.
All four are reported at the diagnostic grid points and carry
lon_grid/lat_grid as their position coords, and only those. The release
positions lon_0/lat_0 are not carried through: on an AuxiliaryFlowMap
they are one stencil arm, which would mislabel an (i, j) quantity.
A single NaN (lost particle or missing stencil point) propagates
gradF -> C -> eigen -> ftle with no special-casing.
Hyperbolic LCS: shrink lines¶
Three module-level functions (in lcs_parcels.tensorlines, exported from the
package root) turn a FlowMap’s strain field into LCS curves, following
Haller (2015) §5.1 / Table 1 (\(n = 2\)):
from lcs_parcels import ftle_ridge_seeds, prune_shrink_lines, shrink_lines
ftle = flowmap.ftle()
seeds = ftle_ridge_seeds(ftle) # seed points
lines = shrink_lines(flowmap, seed_lon=seeds["lon"], seed_lat=seeds["lat"]) # curves
lines = prune_shrink_lines(lines, ftle=ftle) # drop duplicates
A repelling LCS is a shrink line, a curve tangent to the weak-stretch
eigenvector \(\xi_1\) of \(C\), solving the tensor-line ODE \(\dot r = \xi_1(r)\).
Attracting LCS need no separate call: by the forward–backward duality (Haller &
Sapsis 2011,
doi:10.1063/1.3579597) they are the shrink
lines of the backward flow, so shrink_lines of a forward FlowMap gives
repelling LCS and of a backward one gives attracting LCS.
ftle_ridge_seeds(ftle, *, window_m=30_000.0, quantile=None,
ftle_min=None) -> xr.Dataset
shrink_lines(flowmap, *, seed_lon, seed_lat, min_anisotropy=1.15,
step_m=3_000.0, line_length_m=1_500_000.0) -> xr.Dataset
prune_shrink_lines(lines, *, ftle=None, window_m=30_000.0) -> xr.Dataset
Every tuning parameter is stated in the units of the thing itself: metres for
the three lengths, a dimensionless eigenvalue ratio for the guard, the field’s
own units for ftle_min. The three lengths, the ratio and quantile mean the
same thing at any resolution and over any window; ftle_min is an absolute
threshold, so it does not.
ftle_ridge_seeds(ftle)picks seed points at strong local maxima of an FTLE field: grid points that are the maximum over a square window of sidewindow_mmetres (a windowed local maximum on the raw value) and at or above a magnitude floor.window_mis converted to an odd cell count per dimension from the field’s ownlon_grid/lat_gridspacing (the default 30 km is 7 cells on a \(1/25^\circ\) grid at \(20^\circ\)N). The window reacheswindow_m / 2to either side of its own grid point, so two seed points can be as close as aboutwindow_m / 2, notwindow_m; the returnedmin_seed_separation_mattribute is that distance computed on this grid, off its smallest cell, so it is a floor rather than a typical spacing. That floor bounds strict maxima: a plateau of exactly equal values makes every one of its cells a windowed maximum, and those can be adjacent. Awindow_mspanning fewer than three cells in either dimension emits aUserWarning: a one-cell window makes every point a windowed maximum, so the local-maximum test stops selecting and only the floor is left.The floor is set one of two ways, and passing both raises
ValueError.quantileis a quantile of this field, in \([0, 1]\);ftle_minis an absolute value in the units offtle(1/s forflowmap.ftle()). Passing neither usesquantile=0.90, the top decile. Useftle_minwhen several runs (other windows, other regions) have to be compared against one threshold, which a per-field quantile cannot give.The field must carry
lon_grid/lat_grid;flowmap.ftle()does. NaN cells never qualify.Returns an
xr.Datasetwithlon/lat(degrees) on aseeddim, one entry per seed point, and aseedindex coordinate. Itsattrsrecord what the selection did:selector("quantile"or"ftle_min") and theftle_thresholdit resolved to, pluswindow_m, the odd cell countswindow_cells_i/window_cells_jit became, the median grid spacingsgrid_spacing_i_m/grid_spacing_j_mit was measured against, andmin_seed_separation_m.shrink_lines(flowmap, seed_lon=..., seed_lat=...)integrates the \(\xi_1\) tensor line through each seed point, in both directions, on the flow map’s rectilinear grid. It interpolates the tensor \(C\) (viascipy’sRegularGridInterpolator) and re-diagonalises at each step, which is robust to the eigenvector sign ambiguity, then orients each step to the running heading.line_length_mis a cap, not the achieved length: it is traced half in each direction, with \(n = \)line_length_m / (2 * step_m)steps per direction (rounded, at least 1), so a line that runs the full budget has \(2n + 1\) points. A line stops early where \(\xi_1\) stops being a well-defined direction, where \(\lambda_2 / \lambda_1\) falls belowmin_anisotropy, or where it leaves the grid or hits a NaN cell. Returns anxr.Datasetwithlon/laton dims(line, point), onelineper seed point; every row is the same length, NaN-filled past termination, and a seed point that cannot be traced at all is an all-NaN row.prune_shrink_lines(lines, *, ftle=None, window_m=...)drops shrink lines that duplicate a stronger one, which is what several seeds on one ridge produce. Rows that are NaN at every point are dropped first and cover nothing. The remaining lines are ranked by the FTLE integrated along them (ftle_mean * length_m, the FTLE interpolated fromftleat each line’s points), or bylength_malone when noftleis given, and walked from the strongest down. A candidate is dropped once one of its points falls withinwindow_m / 2of an already-kept line’s nearest point and the arc length of its uncovered stretch is belowwindow_m. A line sharing nothing with a stronger line is always kept, whatever its length, and a kept-or-dropped decision is made for the whole line, never a part of it. Returnslinesrestricted to the kept rows, thelinecoord keeping its original labels, pluslength_m(m) and, withftle,ftle_mean(1/s) online. The attributeswindow_m,tube_radius_m,min_new_length_m,n_lines_inandn_lines_droppedrecord what was run. Why the score is a line integral and why the tube radius and minimum new length arewindow_m / 2andwindow_mare innumerics.md.
min_anisotropy is a floor on \(\lambda_2 / \lambda_1\), the ratio of the two
Cauchy–Green eigenvalues, and so dimensionless. It is a well-definedness
guard, not an LCS selector. The magnitude floor in ftle_ridge_seeds
(quantile or ftle_min) is what selects. At the default 1.15 a
1% error in \(C\) swings \(\xi_1\) by about 2 degrees; at a ratio of 1.05 by 6
degrees. Being a ratio it carries no \(T\), no grid scale and no stretching rate,
so 1.15 means the same thing for a six-hour laboratory flow and a six-month
basin-scale one. Its useful range starts just above 1: \(C\) is positive
semi-definite, so \(\lambda_2 \ge \lambda_1 \ge 0\) always, and any
min_anisotropy at or below 1 makes the guard unsatisfiable, so it never fires,
and lines then stop only by leaving the grid or hitting a NaN cell.
This layer interpolates on the axis-aligned lon_grid/lat_grid axes, so (like
NeighborFlowMap) it assumes a rectilinear flow map, and the lon_grid axis
must be monotonic. The traced lines themselves are unconstrained: a line steps
by adding a longitude increment to its current longitude, so it crosses the
antimeridian on the branch its seed point came in on.
One call: FlowMap.hyperbolic_lcs()¶
FlowMap.hyperbolic_lcs(*, window_m=None, quantile=None, ftle_min=None,
min_anisotropy=None, step_m=None,
line_length_m=None) -> xr.Dataset
Runs ftle(), ftle_ridge_seeds, shrink_lines and prune_shrink_lines in
one call, computing the FTLE exactly once and pruning
at the same window_m the seeding used. Every parameter is optional and only
the ones actually passed are forwarded, so the defaults stay in
ftle_ridge_seeds/shrink_lines/prune_shrink_lines, including the rule that
quantile and ftle_min are mutually exclusive, which raises ValueError
from ftle_ridge_seeds when both are passed here. There is no direction
argument: the flow map already carries \(\mathrm{sign}(T)\), so a forward map
yields repelling LCS and a backward one attracting LCS, and the returned
dataset says which family it holds. hyperbolic_lcs() extracts hyperbolic
(repelling and attracting) LCS only.
The result is the pruned shrink_lines dataset, lon/lat on (line, point)
with no all-NaN row, ftle_mean (1/s) and length_m (m) on line, and the
ftle field on (i, j) the seed points were picked from carried along, so the
curves can be plotted over it without recomputing an eigendecomposition. The
line coordinate labels the seed indices that survived pruning rather than
every seed’s original index. The ridge-selection and pruning attributes ride
along too, so lcs.attrs["selector"], lcs.attrs["ftle_threshold"],
lcs.attrs["min_seed_separation_m"] and lcs.attrs["n_lines_dropped"] are
readable off the result without re-running the seeding or the pruning:
lcs = forward.hyperbolic_lcs()
lcs["ftle"].plot(x="lon_grid", y="lat_grid")
plt.plot(lcs["lon"].T, lcs["lat"].T)
lon_grid/lat_grid are 2-D non-dimension coords (the layout that lets the
grid be curvilinear), so a gridded field is drawn against them by passing
x="lon_grid", y="lat_grid"; .plot() on its own falls back to the logical
i/j axes.
Ridge-finding itself takes a field, not a flow map, so to pick seed points from a smoothed or masked FTLE, or to see the lines before pruning, run the steps by hand.
Evolving a material curve¶
An extracted LCS is a material curve, so its later positions are fixed by the
flow: \(\mathcal{M}(t) = F_{t_0}^{t}(\mathcal{M}(t_0))\) (Haller 2015, Eq. 5).
FlowMap.image applies the stored flow map to arbitrary reference points, so a
curve is evolved by interpolating that map at the curve’s vertices, with no
second advection.
FlowMap.image(*, lon_0, lat_0) -> xr.Dataset
image(*, lon_0, lat_0)interpolatesgrid_image(the advected positions on the diagnostic grid) at reference pointslon_0/lat_0(DataArrays on any shared dims, e.g., the(line, point)grid ofshrink_lines), returning their advected positions \(F_{t_0}^{t_1}(x_0)\) as anxr.Datasetwithlon/laton the input dims, the same structure ashrink_linescurve has, so an evolved curve plots the same way and can be passed back intoimage. The requested reference positions ride along aslon_0/lat_0coords on the output. Points off the grid, in a NaN (land/edge) cell, or NaN themselves map to NaN. Rectilinear grids only, likeshrink_lines, with a monotoniclon_gridaxis.The advected longitudes may arrive on any branch. Each is re-anchored on the branch of the grid point it came from before the interpolation, so an advection that hands positions back wrapped to \([-180, 180)\) is read correctly. The returned longitudes are on the branch
lon_0was given in.
An LCS is evolved in its coherent direction, where perturbations decay: an
attracting LCS forward in time, a repelling one backward. Advect the grid to a
few horizons and call image at each to carry the curve through them; see
examples/cabo_verde_lcs_evolution.py.
Output metadata¶
Every array the package returns carries name, long_name, and units, and no
two quantities come back under the same name. That metadata is the axis
label, the title, and the colorbar caption of a vanilla .plot(), so a returned
field needs no hand-set labels to be readable. The only keyword a gridded
diagnostic needs is x="lon_grid", y="lat_grid" to be drawn geographically
rather than against the logical i/j axes.
Returned by |
|
|
|
|---|---|---|---|
|
|
deformation gradient grad F of the flow map |
|
|
|
right Cauchy-Green strain tensor C = (grad F)^T grad F |
|
|
|
eigenvalue lambda of the Cauchy-Green tensor |
|
|
|
eigenvector xi of the Cauchy-Green tensor |
|
|
|
finite-time Lyapunov exponent |
|
|
|
longitude/latitude of the advected position F(x_0) |
|
|
|
longitude/latitude of the FTLE ridge seed |
|
|
|
longitude/latitude along the shrink line |
|
|
|
mean FTLE along the shrink line |
|
|
|
arc length of the shrink line |
|
|
|
longitude/latitude along the repelling (or attracting) LCS |
|
|
|
mean FTLE along the shrink line |
|
|
|
arc length of the shrink line |
|
Units are SI: the FTLE is 1/s, not 1/day. Convert for display in the plotting
code, where the conversion is visible, and re-set units when you do.
The coordinates carry the same metadata, set once at construction
(from_axes, pset_to_flowmap) and unchanged through the diagnostics:
Coord |
|
|
|---|---|---|
|
logical grid index along i / j |
|
|
longitude / latitude |
|
|
longitude/latitude of the reference release position x_0 |
|
|
auxiliary stencil arm |
|
|
release time t0 |
|
|
signed integration window T = t1 - t0 |
|
|
tensor row / column index |
|
|
eigenvector component |
|
|
Cauchy-Green eigenpair, ascending: 0 is the weak-stretch lambda_1, 1 is lambda_max = lambda_2 |
|
|
FTLE ridge seed index |
|
|
shrink line index, one per seed point |
|
|
point index along the shrink line |
The index and label coords (i, j, displacement, row, col, comp,
eig, seed, line, point) carry no units: their values are logical indices or
string labels, so there is no unit to give. t0 and T carry none either,
they are datetime64/timedelta64, so the dtype already holds the unit.
References¶
Haller, G. (2015). Lagrangian Coherent Structures. Annual Review of Fluid Mechanics, 47, 137–162. doi:10.1146/annurev-fluid-010313-141322.
Haller, G. & Sapsis, T. (2011). Lagrangian coherent structures and the smallest finite-time Lyapunov exponent. Chaos, 21, 023115. doi:10.1063/1.3579597.