""" 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, }