Source code for physicskit.classical.systems.lagrangian

"""Lagrangian-mechanics systems, derived symbolically via
:class:`physicskit.classical.utils.symbolic.LagrangianEngine`.

Each system here writes down L(q, qdot) with SymPy, hands it to the
engine to auto-derive the Euler-Lagrange accelerations and the
Legendre-transformed Hamiltonian, and exposes the resulting JIT
equations of motion through :class:`physicskit.classical.core.base_system.LagrangianSystem`.
Because the Hamiltonian form is derived even when the mass matrix M(q)
is configuration-dependent (i.e. the system is non-separable), the
default integration method is ``implicit_midpoint`` -- the general
symplectic integrator from :mod:`physicskit.classical.core.integrators`.

Deriving equations of motion (SymPy differentiation, matrix inversion,
simplification, then Numba compilation) costs on the order of a second
even for a 2-DOF system -- so each system's engine-building step is
wrapped in ``functools.lru_cache``, keyed on the physical parameters
(masses, lengths, ...). Constructing many instances of the *same*
physical system (e.g. an ensemble of initial conditions to show
sensitivity to initial conditions, as in the chaos example) reuses the
cached engine instead of re-deriving and re-compiling from scratch.
"""

from __future__ import annotations

from functools import lru_cache

import numpy as np
import sympy as sp

from physicskit.classical.core.base_system import LagrangianSystem
from physicskit.classical.utils.symbolic import LagrangianEngine

__all__ = ["DoublePendulum", "BeadOnRotatingHoop", "CoupledOscillators", "ElasticPendulum"]


@lru_cache(maxsize=32)
def _double_pendulum_engine(m1: float, m2: float, l1: float, l2: float, g: float) -> LagrangianEngine:
    th1, th2, w1, w2 = sp.symbols("th1 th2 w1 w2")
    m1s, m2s, l1s, l2s, gs = sp.symbols("m1 m2 l1 l2 g")

    x1 = l1s * sp.sin(th1)
    y1 = -l1s * sp.cos(th1)
    x2 = x1 + l2s * sp.sin(th2)
    y2 = y1 - l2s * sp.cos(th2)
    x1d = sp.diff(x1, th1) * w1
    y1d = sp.diff(y1, th1) * w1
    x2d = sp.diff(x2, th1) * w1 + sp.diff(x2, th2) * w2
    y2d = sp.diff(y2, th1) * w1 + sp.diff(y2, th2) * w2

    T = sp.Rational(1, 2) * m1s * (x1d**2 + y1d**2) + sp.Rational(1, 2) * m2s * (x2d**2 + y2d**2)
    V = m1s * gs * y1 + m2s * gs * y2
    L = sp.simplify(T - V)

    params = {m1s: m1, m2s: m2, l1s: l1, l2s: l2, gs: g}
    return LagrangianEngine([th1, th2], [w1, w2], L, params=params)


[docs] class DoublePendulum(LagrangianSystem): """Planar double pendulum: two point masses on massless rods. Generalized coordinates: q = (theta1, theta2), angles from the downward vertical. A classic showcase of deterministic chaos and of the non-separable-Hamiltonian case for symplectic integration (the mass matrix depends on ``theta2 - theta1``). Parameters ---------- theta0, thetadot0 : array-like, shape (2,) Initial angles and angular velocities. m1, m2 : float Bob masses. l1, l2 : float Rod lengths. g : float Gravitational acceleration. """ def __init__(self, theta0, thetadot0, m1=1.0, m2=1.0, l1=1.0, l2=1.0, g=9.81): self.m1, self.m2, self.l1, self.l2, self.g = m1, m2, l1, l2, g self.engine = _double_pendulum_engine(m1, m2, l1, l2, g) self._accel_njit = self.engine.acceleration_njit self._momentum_njit = self.engine.momentum_njit self._canonical_deriv_njit = self.engine.canonical_deriv_njit self._velocity_njit = self.engine.velocity_njit self._deriv_njit = self.engine.full_deriv_njit self._hamiltonian_njit = self.engine.hamiltonian_njit super().__init__(theta0, thetadot0)
[docs] def positions(self, q: np.ndarray = None): """Cartesian (x1, y1, x2, y2) bob positions for a given (theta1, theta2). Parameters ---------- q : ndarray, optional ``(theta1, theta2)``; defaults to the current state. Returns ------- ndarray, shape (4,) ``(x1, y1, x2, y2)``. """ q = self.q if q is None else q th1, th2 = q x1 = self.l1 * np.sin(th1) y1 = -self.l1 * np.cos(th1) x2 = x1 + self.l2 * np.sin(th2) y2 = y1 - self.l2 * np.cos(th2) return np.array([x1, y1, x2, y2])
[docs] def energy(self, state: np.ndarray = None) -> float: state = self.state if state is None else state q, qdot = self.split(state, self.ndof) p = self._momentum_njit(q, qdot) return float(self._hamiltonian_njit(q, p, 0.0))
@lru_cache(maxsize=32) def _bead_on_hoop_engine(R: float, omega: float, g: float) -> LagrangianEngine: th, w = sp.symbols("th w") Rs, Om, gs = sp.symbols("R Omega g") L = sp.Rational(1, 2) * Rs**2 * (w**2 + Om**2 * sp.sin(th) ** 2) - gs * Rs * (1 - sp.cos(th)) params = {Rs: R, Om: omega, gs: g} return LagrangianEngine([th], [w], L, params=params)
[docs] class BeadOnRotatingHoop(LagrangianSystem): """A bead sliding without friction on a hoop of radius R rotating at fixed angular speed Omega about the vertical diameter. Generalized coordinate: q = (theta,), the bead's polar angle on the hoop. In the rotating frame the effective Lagrangian is autonomous (no explicit time dependence), so the system is a genuine 1-DOF conservative Hamiltonian; above the critical speed ``Omega_c = sqrt(g / R)`` the theta=0 equilibrium becomes unstable and two symmetric stable equilibria appear (a pitchfork bifurcation). Parameters ---------- theta0, thetadot0 : float Initial angle and angular velocity. R : float Hoop radius. omega : float Fixed rotation rate ``Omega`` about the vertical diameter. g : float Gravitational acceleration. """ def __init__(self, theta0, thetadot0, R=1.0, omega=2.0, g=9.81): self.R, self.omega, self.g = R, omega, g self.engine = _bead_on_hoop_engine(R, omega, g) self._accel_njit = self.engine.acceleration_njit self._momentum_njit = self.engine.momentum_njit self._canonical_deriv_njit = self.engine.canonical_deriv_njit self._velocity_njit = self.engine.velocity_njit self._deriv_njit = self.engine.full_deriv_njit self._hamiltonian_njit = self.engine.hamiltonian_njit super().__init__(np.atleast_1d(theta0), np.atleast_1d(thetadot0))
[docs] def energy(self, state: np.ndarray = None) -> float: state = self.state if state is None else state q, qdot = self.split(state, self.ndof) p = self._momentum_njit(q, qdot) return float(self._hamiltonian_njit(q, p, 0.0))
@lru_cache(maxsize=32) def _elastic_pendulum_engine(m: float, k: float, L0: float, g: float) -> LagrangianEngine: s, th, sd, wd = sp.symbols("s th sd wd") ms, ks, L0s, gs = sp.symbols("m k L0 g") r = L0s + s T = sp.Rational(1, 2) * ms * (sd**2 + r**2 * wd**2) V = sp.Rational(1, 2) * ks * s**2 - ms * gs * r * sp.cos(th) L = sp.simplify(T - V) params = {ms: m, ks: k, L0s: L0, gs: g} return LagrangianEngine([s, th], [sd, wd], L, params=params) class ElasticPendulum(LagrangianSystem): """Spring pendulum: a mass on a Hookean spring that both stretches and swings, in 2 DOF. Generalized coordinates q = (s, theta): `s` is the spring's stretch beyond its natural length `L0` (so the pivot-to-mass distance is ``r = L0 + s``), `theta` is the swing angle from the downward vertical. Lagrangian: L = (m/2)(sdot^2 + (L0+s)^2 thetadot^2) - (k/2) s^2 + m g (L0+s) cos(theta) The ``r^2 thetadot^2`` term is what couples the (otherwise linear) stretch and swing motions: whenever the mass swings, its varying distance from the pivot pumps the spring, and vice versa. The coupling is strongest, and famously resonant, in *1:2 autoparametric resonance* -- when the natural stretch frequency ``omega_s = sqrt(k/m)`` is twice the natural swing frequency ``omega_theta = sqrt(g/L0)`` -- where energy started as pure vertical stretching oscillation periodically leaks into, and back out of, swinging. The default parameters below satisfy that condition exactly (``k = 4*m*g/L0``). Parameters ---------- q0, qdot0 : array-like, shape (2,) Initial ``(s, theta)`` and ``(sdot, thetadot)``. m : float, default 1.0 Mass. k : float, default 39.24 Spring constant; the default is ``4*m*g/L0`` for the default `m`, `g`, `L0`, i.e. the 1:2 autoparametric resonance condition. L0 : float, default 1.0 Spring natural length. g : float, default 9.81 Gravitational acceleration. """ def __init__(self, q0, qdot0, m=1.0, k=39.24, L0=1.0, g=9.81): self.m, self.k, self.L0, self.g = m, k, L0, g self.engine = _elastic_pendulum_engine(m, k, L0, g) self._accel_njit = self.engine.acceleration_njit self._momentum_njit = self.engine.momentum_njit self._canonical_deriv_njit = self.engine.canonical_deriv_njit self._velocity_njit = self.engine.velocity_njit self._deriv_njit = self.engine.full_deriv_njit self._hamiltonian_njit = self.engine.hamiltonian_njit super().__init__(q0, qdot0) def positions(self, q: np.ndarray = None): """Cartesian (x, y) mass position for a given (s, theta). Parameters ---------- q : ndarray, optional ``(s, theta)``; defaults to the current state. Returns ------- ndarray, shape (2,) ``(x, y)``. """ q = self.q if q is None else q s, th = q r = self.L0 + s return np.array([r * np.sin(th), -r * np.cos(th)]) def energy(self, state: np.ndarray = None) -> float: state = self.state if state is None else state q, qdot = self.split(state, self.ndof) p = self._momentum_njit(q, qdot) return float(self._hamiltonian_njit(q, p, 0.0)) @lru_cache(maxsize=32) def _coupled_oscillators_engine(n: int, m: float, k: float) -> LagrangianEngine: qs = sp.symbols(f"q0:{n}") ws = sp.symbols(f"w0:{n}") ms, ks = sp.symbols("m k") T = sum(sp.Rational(1, 2) * ms * w**2 for w in ws) chain = (0,) + qs + (0,) V = sum(sp.Rational(1, 2) * ks * (chain[i + 1] - chain[i]) ** 2 for i in range(n + 1)) L = sp.simplify(T - V) params = {ms: m, ks: k} return LagrangianEngine(list(qs), list(ws), L, params=params)
[docs] class CoupledOscillators(LagrangianSystem): """A chain of N masses connected by linear springs, both ends fixed to immovable walls -- the small-N, harmonic prototype of the larger lattice chains in :mod:`physicskit.classical.systems.chains`. Generalized coordinates: q_i = displacement of mass i from equilibrium, i = 1..N. Parameters ---------- q0, qdot0 : array-like, shape (n,) Initial displacements and velocities. n : int Number of masses. m, k : float Mass and spring constant (uniform across the chain). """ def __init__(self, q0, qdot0, n=3, m=1.0, k=1.0): self.n_masses = n self.m, self.k = m, k self.engine = _coupled_oscillators_engine(n, m, k) self._accel_njit = self.engine.acceleration_njit self._momentum_njit = self.engine.momentum_njit self._canonical_deriv_njit = self.engine.canonical_deriv_njit self._velocity_njit = self.engine.velocity_njit self._deriv_njit = self.engine.full_deriv_njit self._hamiltonian_njit = self.engine.hamiltonian_njit super().__init__(q0, qdot0)
[docs] def energy(self, state: np.ndarray = None) -> float: state = self.state if state is None else state q, qdot = self.split(state, self.ndof) p = self._momentum_njit(q, qdot) return float(self._hamiltonian_njit(q, p, 0.0))