Spaces:
Runtime error
Runtime error
| """ | |
| 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 | |
| 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, | |
| } | |