Reading how lyapunovs
is implemented, I see that there is no easy way to interfere with the calculation once you call the function. However, in research, you may want/need to do stuff other than the algorithm implemented in ChaosTools.jl. For example, I can think of:
-
Store the evolution of Lyapunov exponents (LEs) after orthonormalization step. This is an easy way to check that the LEs are actually converged.
For example, as I do it in here:
-
Store the whole phase- and tangent-space traces. Useful for debugging/visualization and also required for calculating Covariant Lyapunov Vectors.
-
Calculate quantities related to LEs. For example, LE fluctuations and domination of the Oseledec splitting.
An idea of the low-level API
The idea is to separate the initialization and the loop body of lyapunovs
by providing a problem/integrator-like struct as in DifferentialEquations.jl. In a pseudo-Julia code, like this:
struct LyapunovExponentsSolver{DS}
ds :: DS
Q
λ
# ...and so on...
end
struct MaximumLyapunovExponentsSolver{DS}
ds :: DS
λ
# ...and so on...
end
"""
Construct a `LyapunovExponentsSolver` after the preparation.
"""
function lyapunov_exponents_solver(ds; Ttr=100, dim_lyap=Inf)
u = evolve(ds)
prob = if dim_lyap == 1
MaximumLyapunovExponentsSolver(ds, u)
else
LyapunovExponentsSolver(ds, dim_lyap, u)
end
return prob
end
"""
Evolve the DS `time` and do orthonormalization for each `interval`.
This would be the loop inside the current lyapunov/lyapunovs methods.
"""
solve!(::LyapunovExponentsSolver, time, interval)
# or maybe evolve!(::LyapunovExponentsSolver, ...)?
"""
Return the stored result of LEs.
"""
lyapunovs(::LyapunovExponentsSolver) :: Array
lyapunov(::LyapunovExponentsSolver) :: Real
"""
A high-level API re-defined in terms of the low-level one:
"""
function lyapunovs(ds, args...)
lyapunovs(solve!(lyapunov_exponents_solver(ds), args...))
end
function lyapunov(ds, args...)
lyapunov(solve!(lyapunov_exponents_solver(ds, dim_lyap=1), args...))
end
Usage
A user code for saving LEs after each orthonormalization would be:
function user_code(ds, repeat, time)
solver = lyapunov_exponents_solver(ds, ...)
le_hist = zeros(Float64, (dimension(ds), repeat))
for i in 1:repeat
solve!(solver, time, time) # time == interval so that there would be only one orthonormalization
le_hist[:, i] = lyapunovs(solver) # last LEs
end
le_hist
end
Why a "factory" method lyapunov_exponents_solver
?
You mentioned in the code that qr_sq
is fast. If that's the case, lyapunov_exponents_solver
can be extended so that it returns a different type of solver for which solve!
is defined to use qr_sq
:
struct FullLyapunovExponentsSolver{DS}
ds :: DS
λ
# ...and so on...
end
function lyapunov_exponents_solver(ds; Ttr=100, dim_lyap=Inf)
u = evolve(ds)
prob = if dim_lyap == 1
MaximumLyapunovExponentsSolver(ds, u)
elseif dim_lyap == Inf || dim_lyap == dimension(ds)
FullLyapunovExponentsSolver(ds, u)
else
LyapunovExponentsSolver(ds, dim_lyap, u)
end
return prob
end
So, I thought it'll be nice if we have a user-friendly solver factory which finds the best "solver" given the problem setting.
Thoughts
Naming is rather random and the details of API should be polished. For example, maybe the call signature should be solve!(solver)
instead of solve!(solver, time, interval)
, by storing the duration (step) of the one solve!
call and orthonormalization interval? In the future, you may want to have non-uniform orthonormalization interval for some efficiency. If that's the case, solve!(solver)
would be the forward-compatible API.
But what do you think of the general picture? Do you think it is good to have this kind of low-level API?