Skip to content

Conversation

@hinsley
Copy link

@hinsley hinsley commented Dec 28, 2025

Add Covariant Lyapunov Vectors (CLV) via Ginelli algorithm

This PR implements the clv() function for computing Covariant Lyapunov Vectors using the forward-backward Ginelli algorithm (Ginelli et al., Phys. Rev. Lett. 99, 130601, 2007; arXiv).

What are CLVs?

Covariant Lyapunov Vectors are tangent vectors over a trajectory that characterize the local stability directions of a dynamical system (i.e., they are associated to the Lyapunov exponents). Unlike Gram-Schmidt vectors from the standard Lyapunov exponent computation, CLVs are covariant under the dynamics: if V(t) is a CLV at time t, then after evolution by the tangent dynamics, the resulting vector is parallel to V(t+Δt).

API

result = clv(ds, N; Ttr=1000, Ttr_bkw=500, Δt=0.1)
# Returns named tuple:
#   V::Vector{Matrix{T}}  - CLVs at each time step (D×k matrices)
#   λ::Vector{T}          - Lyapunov exponents
#   x::Vector{StateType}  - State vectors
#   t::Vector{Float64}    - Timestamps

Features

  • Works with both discrete (DeterministicIteratedMap) and continuous (CoupledODEs) systems
  • Supports in-place and out-of-place system definitions
  • Uses TangentDynamicalSystem for automatic or hand-coded Jacobians
  • API follows existing lyapunovspectrum conventions

Tests

  • Diagonal system: CLVs correctly align with coordinate axes
  • Lyapunov consistency: exponents match lyapunovspectrum for Hénon map and Lorenz system
  • Covariance property: verifies J(x)V(t) ∥ V(t+1) for Hénon map
  • Neutral direction: verifies the λ≈0 CLV aligns with the flow direction for Lorenz

Documentation

Added a new "Covariant Lyapunov Vectors" section to lyapunovs.md ("Chaos detection" in the docs) with:

  • Full docstrings including algorithm description with paper citation
  • Plotted examples showing CLVs on the Hénon and Lorenz attractors
  • Convergence guidance for troubleshooting

Performance

Benchmarks for the documentation examples (on Apple M4):

System Call Time Allocations Memory
Hénon (2D discrete) clv(ds, 3000; Ttr=1000, Ttr_bkw=200) 1.29 ms 46k 2.6 MiB
Lorenz (3D continuous) clv(ds, 500; Ttr=1000, Ttr_bkw=1000, Δt=0.05) 60.8 ms 330k 18.3 MiB

Type stability verified with @code_warntype.

Implement the clv function for computing Covariant Lyapunov Vectors
using the forward-backward Ginelli algorithm from Ginelli et al.,
Phys. Rev. Lett. 99, 130601, 2007.

Features:
- Works with both discrete and continuous dynamical systems
- Supports in-place and out-of-place system definitions
- Uses TangentDynamicalSystem for automatic or hand-coded Jacobians
- Returns CLVs, Lyapunov exponents, trajectory states, and timestamps

Implementation details:
- Forward pass: QR decomposition with positive-diagonal sign correction
- Backward pass: Back-substitution to obtain coefficient matrices
- Proper orthonormalization (signed-corrected QR) to ensure CLV uniqueness

Tests:
- Diagonal system test verifying CLVs align with coordinate axes
- Lyapunov exponent consistency with lyapunovspectrum
- Covariance property verification for Henon map
- Neutral CLV alignment with flow direction for Lorenz system

Documentation:
- Comprehensive docstrings with algorithm description
- Convergence troubleshooting notes
- Visualized examples with Henon 2D and Lorenz 3D attractors
@Datseris
Copy link
Member

Datseris commented Jan 9, 2026

Hello @hinsley thank you very much for the contribution. I've been away from GitHub for a while but I am back now and I will review your PR soon, or as s oon as I can!

May I also ask how you use this package or thi new functionality? Perhaps there is an opportunity to give a talk at an upcoming JuliaDynamics meeting.

@codecov-commenter
Copy link

codecov-commenter commented Jan 9, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 65.35%. Comparing base (928bdb0) to head (44a3c82).
⚠️ Report is 24 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff             @@
##             main     #357       +/-   ##
===========================================
+ Coverage   54.86%   65.35%   +10.49%     
===========================================
  Files          22       27        +5     
  Lines         904     1146      +242     
===========================================
+ Hits          496      749      +253     
+ Misses        408      397       -11     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@hinsley
Copy link
Author

hinsley commented Jan 14, 2026

Sorry, I was distracted for a few days.

May I also ask how you use this package or thi new functionality? Perhaps there is an opportunity to give a talk at an upcoming JuliaDynamics meeting.

I mainly use this algorithm in parameter sweeps of maps & flows to look for (pseudo)hyperbolic attractors. A good example of how this can be done with CLVs is found in this 2018 paper, though they employ some tricks related to those from the Kuptsov & Parlitz paper to avoid explicitly computing vectors, and they're only looking at hand-selected parameter values rather than doing sweeps.

The basic idea is to consider some stable and unstable tangent subspaces spanned by a certain number* of leading and lagging CLVs (corresponding to greatest/least Lyapunov exponents) and ask whether these subspaces intersect nontrivially anywhere, or alternatively whether the minimal angle between the subspaces stays bounded away from zero. If there's a nontrivial intersection, you have a violation of pseudohyperbolicity and homoclinic tangencies indicating lack of structural stability (pseudohyperbolicity guarantees structural stability). In the additions to the docs for this PR I included a couple of visualizations of the CLVs so one can see what these subspace intersections look like in practice.

*For example, in the Hénon map (2D), there's just one leading CLV, corresponding to a positive Lyapunov exponent and indicating the direction of greatest expansion, and one lagging CLV, with a negative exponent and indicating greatest contraction. In Lorenz, you've got a positive exponent, a zero exponent, and a negative exponent. In general (see the paper linked above) the "contracting subspace" needs to contain only CLVs associated with negative Lyapunov exponents, so we take 2 leading CLVs to span the "center-unstable subspace" $E^{cu}$ and 1 lagging CLV to span the stable subspace $E^{ss}$. At the usual parameter values, $E^{cu}$ and $E^{ss}$ stay bounded away from each other in angle at each point on the attractor, but when the wings of the Lorenz attractor start folding over you'll find that the line $E^{ss}$ resides within the plane $E^{cu}$ in some places.

One could think of the minimum angle between contracting and expanding subspaces as a kind of degree of hyperbolicity of an invariant set, but there are some technicalities to keep in mind about non-uniqueness of dimension of these subspaces if the codimension of the orbits is greater than 2 (maps dim > 2, flows dim > 3).


I have seen these CLVs used in climate sciences in more sophisticated ways, though I believe they're working in higher-dimensional spaces where you maybe don't want to use the Ginelli algorithm. Presumably it is first and foremost used as a dimensionality reduction technique for forecasting, since you can calculate errors coming just from the center-unstable bundle instead of the full tangent bundle. I don't know anything about this area though, so this is just a guess.


I am pretty interested in also understanding the Kuptsov & Parlitz approach to CLVs, which is what it seems like the applied dynamicists tend to reach for. There's an existing Python implementation at https://github.com/ThomasSavary08/Lyapynov I'd probably study alongside the original paper. If the JuliaDynamics talks are intended to be more than 5-10 minutes, I think I would feel more prepared waiting until I had also played with uses of the K&P algorithm and maybe contributed it to this repo in another PR.

If there's any interest in seeing how to use the CLV algorithm from this PR to study degree of hyperbolicity in dynamical attractors, I could give a talk. But this would really feel more like a brief presentation of a special method rather than a survey of the uses of CLVs. Up to you (or anyone else interested).

@Datseris
Copy link
Member

Hello, sorry for being late I am swamped! I think this sounds generally interesting and if you want to chat about this you are welcomed to do so. Feel free even to pick a slot and write your name directly in this sheet here: https://docs.google.com/spreadsheets/d/19MrgrHdhy6r1x8OUS-B3Gj1WEMIAToCNRabBimCzuCk/edit?gid=1016767766#gid=1016767766 !

@Datseris
Copy link
Member

I'll now go ahead to finally review this PR and sorry for being late.

Copy link
Member

@Datseris Datseris left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a fantastic contribution. Thank you very much. It is rare to see this level of quality for the first contribution of someone! Congratulations! I only left minor comments on the PR. I did go through the source code, and all performance fundamentals of type stability and unecessary allocations seem to be covered. Did you by any chance also do some profiling yourself, or something similar, to optimize the performance of the code? EDIT: I read in the first post:

Type stability verified with @code_warntype.

Docs all are great.

Besides the comments below, can you also add changelog entry and bump minor version in Proejct.toml?

Lastly, I am sorry but I need to ask this in this day and age. Are parts of this code generated by GenAI? If so, this will be the very first code in DynamicalSystems.jl that has this property, and so we would need to put a disclaimer somewhere.

Comment on lines +265 to +272
Covariant Lyapunov vectors (CLVs) are tangent vectors of a dynamical system corresponding to the directions of contraction and expansion described by the Lyapunov exponents. In 2007, Ginelli et al. [Ginelli2007](@cite) introduced a numerical method for computing CLVs, based on the Benettin method used for computing Lyapunov exponents. We do not currently implement the alternative algorithm from the 2007 Wolfe and Samelson paper.

Note that the most non-central directions converge first, and the least expanding/contracting directions converge last. In particular, the most contracting direction converges first in the forward pass, and the most expanding direction converges first in the backward pass (both passes are computed in `clv()`). To see if you're getting the correct CLVs, you can check the Lyapunov exponents of the computed CLVs against a much longer trajectory using `lyapunovspectrum`, and in continuous-time systems you can check that the CLV corresponding to the flow direction is parallel to the flow direction at each point in the trajectory; if not, try the following:

- Use a different integrator
- Increase the length of forward or backward transients
- Decrease the timestep size
- Increase the orthonormalization/QR frequency by decreasing `Δt`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs of ChaosTools.jl are admittedly the olders in JuliaDynamics and do not follow many of the improvments done... For now, can you please move such discussions to the ## Description section inside the documentation string of the function?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, can you please use some tooling that limits lines of source code to 92 when you do port this to the source code?

For continuous systems this is approximate.
* `show_progress = false`: Display a progress bar.

## Algorithm
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## Algorithm
## Description

Comment on lines +72 to +73
The CLVs have the key property of being *covariant*: if V(t) is a CLV at time t,
then after evolution by the tangent dynamics, the resulting vector is parallel to V(t+Δt).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a bit confused reading this. I am not sure where "the resulting vector" alludes to. After evolution by tangent dynamics, V(t) has become V(t+Δt). So the resulting vector is exactly V(t+Δt), not just parallel to it. I am misundersrtanding something.

Comment on lines +37 to +44
## Returns

A named tuple `(V, λ, x, t)` where:
- `V::Vector{Matrix}`: CLVs at each stored time step. `V[i]` is a `D×k` matrix whose columns
are the `k` CLVs at time `t[i]`, ordered from most expanding to most contracting.
- `λ::Vector`: Lyapunov exponents (growth rates along CLVs), ordered from largest to smallest.
- `x::Vector`: State vectors at each stored time step.
- `t::Vector`: Time stamps corresponding to each stored CLV snapshot.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would challenge the named tuple return type as being an uncommon practice. I would favour returning a tuple, and not saying that explicitly as that is what all functions do. So do not say anything, just start wit hthe bullet point directly.

Use this method for looping over different initial conditions or parameters by
calling [`reinit!`](@ref) on `tands`, or when you have a hand-coded Jacobian.

## Returns
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## Returns
## Return

Comment on lines +44 to +45
b = Statistics.covm(x, mx, y, my) / Statistics.varm(x, mx)
a = my - b * mx
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
b = Statistics.covm(x, mx, y, my) / Statistics.varm(x, mx)
a = my - b * mx
b = Statistics.covm(x, mx, y, my)/Statistics.varm(x, mx)
a = my - b*mx

# Creation of a trivial system with one coordinate going to infinity
# and the other to zero. Lyapunov exponents are known analytically
trivial_rule(x, p, n) = SVector{2}(p[1]*x[1], p[2]*x[2])
trivial_rule(x, p, n) = SVector{2}(p[1] * x[1], p[2] * x[2])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please revert all automatic formatting done in this file.

@Datseris
Copy link
Member

Datseris commented Jan 23, 2026

oh additionally the arrows! plotting function is deprecated, please use the arrows2d or 3d versions! and:

arrowsize has been deprecated in favor of tipradius and tiplength.

Store snapshots of current_state with recursivecopy so x_history does not alias
a mutable view and collapse to the final state.
end
end

@testset "CLV Lyapunov consistency" begin
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, I took a closer look at the tests after your latest commit. I have two comments: 1) there should be a test that access the history of the CLVs, not just the final point, so that we would have caught what you just fixed. 2) can we reduce the computational cost of the tests? At the moment to test each property you are interested in, the whole CLV estimation is run. But I'd argue for most properties you can run CLVs once and then check each property in an inner testset.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants