Source code for gala.dynamics.nonlinear

import astropy.units as u
import numpy as np
from scipy.signal import argrelmin

from .core import PhaseSpacePosition
from .orbit import Orbit

__all__ = ["fast_lyapunov_max", "lyapunov_max", "surface_of_section"]


[docs] def fast_lyapunov_max( w0, hamiltonian, dt, n_steps, d0=1e-5, n_steps_per_pullback=10, noffset_orbits=2, t1=0.0, atol=1e-10, rtol=1e-10, nmax=0, return_orbit=True, ): """ Compute the maximum Lyapunov exponent using a fast C-implemented method. This function estimates the maximum Lyapunov exponent by integrating the orbit along with several nearby offset orbits and tracking their separation over time. It uses the DOPRI853 integrator for high accuracy. Parameters ---------- w0 : :class:`~gala.dynamics.PhaseSpacePosition` or array_like Initial conditions for the primary orbit. hamiltonian : :class:`~gala.potential.Hamiltonian` The Hamiltonian system to integrate in. Must contain a C-implemented potential and frame. dt : float Integration timestep. n_steps : int Number of integration steps to run. d0 : float, optional Initial separation between the primary orbit and offset orbits. Default is 1e-5. n_steps_per_pullback : int, optional Number of integration steps between each renormalization of the offset vectors. Default is 10. noffset_orbits : int, optional Number of offset orbits to use. Default is 2. t1 : float, optional Initial time. Default is 0.0. atol : float, optional Absolute tolerance for the integrator. Default is 1e-10. rtol : float, optional Relative tolerance for the integrator. Default is 1e-10. nmax : int, optional Maximum number of function evaluations. Default is 0 (no limit). return_orbit : bool, optional Whether to return the integrated orbit along with the Lyapunov exponent. Default is True. Returns ------- LEs : :class:`~astropy.units.Quantity` The Lyapunov exponents calculated from each offset orbit. orbit : :class:`~gala.dynamics.Orbit`, optional The integrated primary orbit (returned only if ``return_orbit=True``). Raises ------ TypeError If the Hamiltonian does not contain C-implemented components. ValueError If trying to compute Lyapunov exponents for multiple orbits simultaneously. Notes ----- The Lyapunov exponent quantifies the rate of exponential divergence of nearby trajectories in phase space. Positive values indicate chaotic motion, while zero or negative values suggest regular motion. This implementation is optimized for speed and uses C code with the DOPRI853 adaptive Runge-Kutta integrator. """ from gala.potential import PotentialBase from .lyapunov import dop853_lyapunov_max, dop853_lyapunov_max_dont_save # TODO: remove in v1.0 if isinstance(hamiltonian, PotentialBase): from ..potential import Hamiltonian hamiltonian = Hamiltonian(hamiltonian) if not hamiltonian.c_enabled: raise TypeError( "Input Hamiltonian must contain a C-implemented potential and frame." ) if not isinstance(w0, PhaseSpacePosition): w0 = np.asarray(w0) ndim = w0.shape[0] // 2 w0 = PhaseSpacePosition(pos=w0[:ndim], vel=w0[ndim:], copy=False) w0_ = np.squeeze(w0.w(hamiltonian.units)) if w0_.ndim > 1: raise ValueError("Can only compute fast Lyapunov exponent for a single orbit.") if return_orbit: t, w, l = dop853_lyapunov_max( hamiltonian, w0_, dt, n_steps + 1, t1, d0, n_steps_per_pullback, noffset_orbits, atol, rtol, nmax, ) w = np.rollaxis(w, -1) try: tunit = hamiltonian.units["time"] except (TypeError, AttributeError): tunit = u.dimensionless_unscaled orbit = Orbit.from_w( w=w, units=hamiltonian.units, t=t * tunit, hamiltonian=hamiltonian, copy=False, ) return l / tunit, orbit l = dop853_lyapunov_max_dont_save( hamiltonian, w0_, dt, n_steps + 1, t1, d0, n_steps_per_pullback, noffset_orbits, atol, rtol, nmax, ) try: tunit = hamiltonian.units["time"] except (TypeError, AttributeError): tunit = u.dimensionless_unscaled return l / tunit
[docs] def lyapunov_max( w0, integrator, dt, n_steps, d0=1e-5, n_steps_per_pullback=10, noffset_orbits=8, t1=0.0, units=None, rng=None, ): """ Compute the maximum Lyapunov exponent of an orbit by integrating many nearby orbits (``noffset``) separated with isotropically distributed directions but the same initial deviation length, ``d0``. This algorithm re-normalizes the offset orbits every ``n_steps_per_pullback`` steps. Parameters ---------- w0 : `~gala.dynamics.PhaseSpacePosition`, array_like Initial conditions. integrator : `~gala.integrate.Integrator` An instantiated `~gala.integrate.Integrator` object. Must have a run() method. dt : numeric Timestep. n_steps : int Number of steps to run for. d0 : numeric (optional) The initial separation. n_steps_per_pullback : int (optional) Number of steps to run before re-normalizing the offset vectors. noffset_orbits : int (optional) Number of offset orbits to run. t1 : numeric (optional) Time of initial conditions. Assumed to be t=0. units : `~gala.units.UnitSystem` (optional) If passing in an array (not a `~gala.dynamics.PhaseSpacePosition`), you must specify a unit system. rng : `numpy.random.Generator` (optional) If provided, will use this random number generator to generate the initial offset vectors. If not provided, will use the default ``numpy.random.default_rng()``. Returns ------- LEs : :class:`~astropy.units.Quantity` Lyapunov exponents calculated from each offset / deviation orbit. orbit : `~gala.dynamics.Orbit` """ if units is not None: pos_unit = units["length"] vel_unit = units["length"] / units["time"] else: pos_unit = u.dimensionless_unscaled vel_unit = u.dimensionless_unscaled if not isinstance(w0, PhaseSpacePosition): w0 = np.asarray(w0) ndim = w0.shape[0] // 2 w0 = PhaseSpacePosition( pos=w0[:ndim] * pos_unit, vel=w0[ndim:] * vel_unit, copy=False ) w0_ = w0.w(units) ndim = 2 * w0.ndim # number of iterations niter = n_steps // n_steps_per_pullback # define offset vectors to start the offset orbits on rng = rng or np.random.default_rng() d0_vec = rng.uniform(size=(ndim, noffset_orbits)) d0_vec /= np.linalg.norm(d0_vec, axis=0)[np.newaxis] d0_vec *= d0 w_offset = w0_ + d0_vec all_w0 = np.hstack((w0_, w_offset)) # array to store the full, main orbit full_w = np.zeros((ndim, n_steps + 1, noffset_orbits + 1)) full_w[:, 0] = all_w0 full_ts = np.zeros((n_steps + 1,)) full_ts[0] = t1 # arrays to store the Lyapunov exponents and times LEs = np.zeros((niter, noffset_orbits)) ts = np.zeros_like(LEs) time = t1 total_steps_taken = 0 for i in range(1, niter + 1): ii = i * n_steps_per_pullback orbit = integrator(all_w0, dt=dt, n_steps=n_steps_per_pullback, t1=time) tt = orbit.t.value ww = orbit.w(units) time += dt * n_steps_per_pullback main_w = ww[:, -1, 0:1] d1 = ww[:, -1, 1:] - main_w d1_mag = np.linalg.norm(d1, axis=0) LEs[i - 1] = np.log(d1_mag / d0) ts[i - 1] = time w_offset = ww[:, -1, 0:1] + d0 * d1 / d1_mag[np.newaxis] all_w0 = np.hstack((ww[:, -1, 0:1], w_offset)) full_w[:, (i - 1) * n_steps_per_pullback + 1 : ii + 1] = ww[:, 1:] full_ts[(i - 1) * n_steps_per_pullback + 1 : ii + 1] = tt[1:] total_steps_taken += n_steps_per_pullback LEs = np.array([LEs[:ii].sum(axis=0) / ts[ii - 1] for ii in range(1, niter)]) try: t_unit = units["time"] except (TypeError, AttributeError): t_unit = u.dimensionless_unscaled orbit = Orbit.from_w( w=full_w[:, :total_steps_taken], units=units, t=full_ts[:total_steps_taken] * t_unit, ) return LEs / t_unit, orbit
[docs] def surface_of_section(orbit, constant_idx, constant_val=0.0): """ Generate and return a surface of section from the given orbit. Parameters ---------- orbit : `~gala.dynamics.Orbit` The input orbit to generate a surface of section for. constant_idx : int Integer that represents the coordinate to record crossings in. For example, for a 2D Hamiltonian where you want to make a SoS in :math:`y-p_y`, you would specify ``constant_idx=0`` (crossing the :math:`x` axis), and this will only record crossings for which :math:`p_x>0`. Returns ------- sos : numpy ndarray TODO: - Implement interpolation to get the other phase-space coordinates truly at the plane, instead of just at the orbital position closest to the plane. """ if orbit.norbits > 1: raise NotImplementedError("Not yet implemented, sorry!") w = [getattr(orbit, x) for x in orbit.pos_components] + [ getattr(orbit, v) for v in orbit.vel_components ] ndim = orbit.ndim p_ix = constant_idx + ndim # record position on specified plane when orbit crosses cross_idx = argrelmin((w[constant_idx] - constant_val) ** 2)[0] cross_idx = cross_idx[w[p_ix][cross_idx] > 0.0] sos_pos = [w[i][cross_idx] for i in range(ndim)] sos_pos = orbit.pos.__class__(*sos_pos) sos_vel = [w[i][cross_idx] for i in range(ndim, 2 * ndim)] sos_vel = orbit.vel.__class__(*sos_vel) return Orbit(sos_pos, sos_vel, copy=False)