pendulum-gravity-lab / pendulum_physics.py
Ahmed Darwish
Deploy interactive pendulum simulation
72ae1e2
Raw
History Blame Contribute Delete
5.18 kB
"""
pendulum_physics.py -- self-contained nonlinear pendulum physics core for
the interactive simulation. Deliberately has ZERO dependency on the
vision_lab/ package (no OpenCV) so this folder can be deployed alone as a
minimal Hugging Face Space / Gradio app that works for anyone, anywhere,
with no camera or physical hardware at all.
Equation of motion (damped, driven-free nonlinear pendulum):
theta'' + (b/m) * theta' + (g/L) * sin(theta) = 0
Solved with a fixed-step classical RK4 integrator (no SciPy ODE solver
dependency required, though SciPy is used for the exact elliptic-integral
period as an independent cross-check of the numerical integration).
"""
from __future__ import annotations
from dataclasses import dataclass
from math import pi, sin, sqrt
from typing import Optional, Tuple
import numpy as np
from scipy.special import ellipk
def _derivatives(theta: float, omega: float, g: float, L: float, damping: float) -> Tuple[float, float]:
theta_dot = omega
omega_dot = -damping * omega - (g / L) * sin(theta)
return theta_dot, omega_dot
def simulate(
theta0_rad: float,
g: float,
L: float,
damping: float = 0.0,
omega0: float = 0.0,
t_max: float = 10.0,
dt: float = 0.001,
) -> dict:
"""Integrate the exact nonlinear pendulum ODE with RK4.
Returns a dict of numpy arrays: t, theta (rad), omega (rad/s),
theta_small_angle (the SHM analytic comparison at the same theta0),
energy (per unit mass, J/kg).
"""
n = int(t_max / dt) + 1
t = np.linspace(0.0, t_max, n)
theta = np.empty(n)
omega = np.empty(n)
theta[0], omega[0] = theta0_rad, omega0
for i in range(1, n):
th, om = theta[i - 1], omega[i - 1]
k1_th, k1_om = _derivatives(th, om, g, L, damping)
k2_th, k2_om = _derivatives(th + 0.5 * dt * k1_th, om + 0.5 * dt * k1_om, g, L, damping)
k3_th, k3_om = _derivatives(th + 0.5 * dt * k2_th, om + 0.5 * dt * k2_om, g, L, damping)
k4_th, k4_om = _derivatives(th + dt * k3_th, om + dt * k3_om, g, L, damping)
theta[i] = th + (dt / 6.0) * (k1_th + 2 * k2_th + 2 * k3_th + k4_th)
omega[i] = om + (dt / 6.0) * (k1_om + 2 * k2_om + 2 * k3_om + k4_om)
omega_0_shm = sqrt(g / L)
theta_shm = theta0_rad * np.cos(omega_0_shm * t)
# Energy per unit mass: (1/2) L^2 omega^2 + g*L*(1 - cos(theta))
energy = 0.5 * (L ** 2) * omega ** 2 + g * L * (1.0 - np.cos(theta))
return {
"t": t,
"theta": theta,
"omega": omega,
"theta_small_angle": theta_shm,
"energy": energy,
}
def period_small_angle(g: float, L: float) -> float:
return 2.0 * pi * sqrt(L / g)
def period_exact(theta0_rad: float, g: float, L: float) -> float:
"""Exact nonlinear pendulum period via the complete elliptic integral
of the first kind: T = 4*sqrt(L/g) * K(sin^2(theta0/2))
(SciPy's ``ellipk(m)`` uses the parameter convention m = k^2.)
"""
m = sin(theta0_rad / 2.0) ** 2
return 4.0 * sqrt(L / g) * ellipk(m)
def period_series_correction(theta0_rad: float) -> float:
"""Fractional correction T_exact/T0 - 1, leading-order series
(matches vision_lab/physics.py::small_angle_period_correction, and
both are cross-checked against each other and against the exact
elliptic-integral result in the test suite)."""
theta = theta0_rad
return (theta ** 2) / 16.0 + (11.0 * theta ** 4) / 3072.0
@dataclass(frozen=True)
class VirtualTrial:
length_m: float
g_true: float
theta0_rad: float
n_oscillations: int
g_measured: float
sigma_g: float
def virtual_experiment(
length_m: float,
g_true: float,
theta0_rad: float,
n_oscillations: int = 20,
timing_noise_s: float = 0.02,
rng: Optional[np.random.Generator] = None,
) -> VirtualTrial:
"""Numerically reproduce the *camera experiment's* measurement method
(time N oscillations, divide, invert g=4pi^2L/T^2) on a numerically
exact nonlinear pendulum, with injected Gaussian timing noise -- i.e.
a digital twin of vision_lab/pendulum_tracker.py that needs no camera.
"""
if rng is None:
rng = np.random.default_rng()
t_true = period_exact(theta0_rad, g_true, length_m)
total_time_true = t_true * n_oscillations
# Two independent noisy timing reads (start & end crossing detection),
# combined -- mirrors the real tracker's start/end crossing timestamps.
noisy_total_time = total_time_true + rng.normal(0.0, timing_noise_s) - rng.normal(0.0, timing_noise_s)
T_measured = noisy_total_time / n_oscillations
g_measured = 4.0 * pi ** 2 * length_m / T_measured ** 2
sigma_T = timing_noise_s * sqrt(2.0) / n_oscillations
sigma_g = g_measured * sqrt((2 * sigma_T / T_measured) ** 2)
return VirtualTrial(
length_m=length_m,
g_true=g_true,
theta0_rad=theta0_rad,
n_oscillations=n_oscillations,
g_measured=g_measured,
sigma_g=sigma_g,
)
PLANETS = {
"Earth / الأرض": 9.80665,
"Moon / القمر": 1.62,
"Mars / المريخ": 3.71,
"Jupiter / المشتري": 24.79,
"Saturn / زحل": 10.44,
}