Source code for mathematicskit.ode_dynamics.systems.poincare

r"""Poincare sections for higher-dimensional (here: periodically-driven,
effectively 3D via the phase of the drive) flows, demonstrated on the
forced Duffing oscillator.

See Strogatz, *Nonlinear Dynamics and Chaos*, 2nd ed., Ch. 9 ("Poincare
Maps") and Ch. 12 ("Chaos and the Duffing oscillator" case study).
"""

from __future__ import annotations

from functools import lru_cache

import numpy as np
from numba import njit

from mathematicskit.ode_dynamics.core.base import FlowSystem

__all__ = ["DuffingOscillator", "stroboscopic_poincare_section"]


@lru_cache(maxsize=64)
def _make_duffing_rhs(delta: float, alpha: float, beta: float, gamma: float, omega: float):
    @njit(cache=True)
    def rhs(state, t, params):
        x, y = state[0], state[1]
        out = np.empty(2)
        out[0] = y
        out[1] = gamma * np.cos(omega * t) - delta * y - alpha * x - beta * x**3
        return out

    return rhs


[docs] class DuffingOscillator(FlowSystem): r"""Periodically-driven Duffing oscillator. :math:`\ddot x + \delta \dot x + \alpha x + \beta x^3 = \gamma \cos(\omega t)`. Explicit time-dependence (through the drive) makes the ``(x, y)`` phase portrait alone insufficient to see the full (3D, including drive phase) state space; a *stroboscopic* Poincare section -- sampling ``(x, y)`` once per drive period -- reduces the long-time behavior back to a 2D picture, revealing periodic points for periodic motion and a fractal attractor for chaotic parameters. See Strogatz, *Nonlinear Dynamics and Chaos*, 2nd ed., Ch. 12.3. Parameters ---------- state0 : array-like, shape (2,) delta : float Damping. alpha, beta : float Linear and cubic stiffness (``alpha<0, beta>0`` gives the classic double-well potential). gamma, omega : float Drive amplitude and angular frequency. Examples -------- >>> system = DuffingOscillator([1.0, 0.0], delta=0.3, alpha=-1.0, beta=1.0, gamma=0.5, omega=1.2) >>> result = system.integrate((0.0, 10.0), dt=1e-3, method="rk4") >>> result.y.shape[1] 2 """ def __init__(self, state0, delta: float = 0.3, alpha: float = -1.0, beta: float = 1.0, gamma: float = 0.3, omega: float = 1.2): self.delta, self.alpha, self.beta, self.gamma, self.omega = float(delta), float(alpha), float(beta), float(gamma), float(omega) self._rhs_njit = _make_duffing_rhs(self.delta, self.alpha, self.beta, self.gamma, self.omega) self.params = np.empty(0) super().__init__(state0)
[docs] def rhs(self, state: np.ndarray, t: float = 0.0) -> np.ndarray: x, y = state[0], state[1] return np.array([y, self.gamma * np.cos(self.omega * t) - self.delta * y - self.alpha * x - self.beta * x**3])
[docs] def stroboscopic_poincare_section(system: DuffingOscillator, n_periods: int, dt: float = 1e-3, n_transient_periods: int = 20): """Sample ``(x, y)`` once per drive period after discarding transients. Parameters ---------- system : DuffingOscillator n_periods : int Number of drive periods to record after the transient. dt : float Fixed integration step (must divide the drive period reasonably finely). n_transient_periods : int Number of drive periods discarded before recording begins. Returns ------- xs, ys : ndarray, shape (n_periods,) The stroboscopic (once-per-period) samples. Examples -------- >>> system = DuffingOscillator([1.0, 0.0], delta=0.3, alpha=-1.0, beta=1.0, gamma=0.3, omega=1.2) >>> xs, ys = stroboscopic_poincare_section(system, n_periods=5, n_transient_periods=5, dt=1e-2) >>> len(xs) 5 """ period = 2.0 * np.pi / system.omega n_steps_per_period = max(int(round(period / dt)), 1) actual_dt = period / n_steps_per_period system.integrate((0.0, n_transient_periods * period), dt=actual_dt, method="rk4") xs = np.empty(n_periods) ys = np.empty(n_periods) for k in range(n_periods): result = system.integrate((system.t, system.t + period), dt=actual_dt, method="rk4") xs[k] = result.y[-1, 0] ys[k] = result.y[-1, 1] return xs, ys