pendulum-gravity-lab / test_pendulum_physics.py
Ahmed Darwish
Deploy interactive pendulum simulation
72ae1e2
Raw
History Blame Contribute Delete
5.44 kB
"""
Unit tests for the simulation's physics core -- no Gradio/UI involved,
so these run in any CI environment with just numpy + scipy installed.
Run with: python -m unittest test_pendulum_physics.py -v
"""
import math
import unittest
import numpy as np
from pendulum_physics import (
period_exact,
period_series_correction,
period_small_angle,
simulate,
virtual_experiment,
)
class TestSmallAngleLimit(unittest.TestCase):
def test_exact_period_converges_to_small_angle_limit(self):
"""At a vanishingly small amplitude, the exact nonlinear period
must converge to the SHM analytic period."""
g, L = 9.80665, 0.30
T0 = period_small_angle(g, L)
T_tiny = period_exact(math.radians(0.01), g, L)
self.assertAlmostEqual(T_tiny, T0, places=5)
def test_series_correction_matches_exact_elliptic_at_15_degrees(self):
g, L = 9.81, 0.30
theta0 = math.radians(15)
T0 = period_small_angle(g, L)
T_exact = period_exact(theta0, g, L)
exact_fraction = T_exact / T0 - 1.0
series_fraction = period_series_correction(theta0)
self.assertAlmostEqual(exact_fraction, series_fraction, places=4)
def test_correction_grows_with_amplitude(self):
c10 = period_series_correction(math.radians(10))
c30 = period_series_correction(math.radians(30))
c90 = period_series_correction(math.radians(90))
self.assertLess(c10, c30)
self.assertLess(c30, c90)
class TestIntegratorPhysics(unittest.TestCase):
def test_energy_conserved_without_damping(self):
res = simulate(math.radians(30), 9.81, 0.3, damping=0.0, t_max=8.0, dt=0.001)
energy = res["energy"]
relative_drift = (energy.max() - energy.min()) / energy.mean()
self.assertLess(relative_drift, 1e-6)
def test_energy_monotonically_decreases_with_damping(self):
res = simulate(math.radians(30), 9.81, 0.3, damping=0.8, t_max=8.0, dt=0.001)
energy = res["energy"]
# sampled every 200 steps to ignore within-cycle KE/PE exchange noise
sampled = energy[::200]
diffs = np.diff(sampled)
self.assertTrue((diffs <= 1e-9).all())
def test_zero_amplitude_stays_at_rest(self):
res = simulate(0.0, 9.81, 0.3, damping=0.0, t_max=2.0, dt=0.01)
self.assertTrue(np.allclose(res["theta"], 0.0, atol=1e-9))
self.assertTrue(np.allclose(res["omega"], 0.0, atol=1e-9))
def test_period_from_integration_matches_elliptic_prediction(self):
"""Cross-check: find the first zero-crossing time of theta(t) in
the numerical integration and confirm a quarter period matches the
analytic elliptic-integral period prediction."""
g, L = 9.81, 0.3
theta0 = math.radians(20)
res = simulate(theta0, g, L, damping=0.0, t_max=5.0, dt=0.0002)
theta = res["theta"]
t = res["t"]
sign_changes = np.where(np.diff(np.sign(theta)) != 0)[0]
self.assertGreater(len(sign_changes), 0)
t_quarter_period_numeric = t[sign_changes[0]]
T_exact = period_exact(theta0, g, L)
self.assertAlmostEqual(t_quarter_period_numeric, T_exact / 4.0, delta=0.01)
class TestVirtualExperiment(unittest.TestCase):
def test_zero_noise_still_shows_small_angle_systematic_bias(self):
"""With timing noise switched off, virtual_experiment is NOT expected
to recover g_true exactly: the measurement method itself assumes the
small-angle (SHM) relation g=4*pi^2*L/T^2, but the ball is timed on
its *exact* nonlinear trajectory -- so a small, deterministic,
amplitude-dependent bias survives even with perfect timing. This is
a feature, not a bug: it is exactly the ~0.19% (at 10 deg) systematic
error that motivates keeping release angles small in vision_lab/.
"""
g_true = 9.81
theta0 = math.radians(10)
rng = np.random.default_rng(0)
trial = virtual_experiment(0.3, g_true, theta0, n_oscillations=20,
timing_noise_s=0.0, rng=rng)
expected_bias_factor = (1.0 + period_series_correction(theta0)) ** 2
expected_g = g_true / expected_bias_factor
self.assertAlmostEqual(trial.g_measured, expected_g, places=6)
# And that bias should be small but non-zero at this amplitude.
self.assertLess(abs(trial.g_measured - g_true), 0.1)
self.assertGreater(abs(trial.g_measured - g_true), 1e-6)
def test_more_oscillations_reduce_scatter_across_many_trials(self):
g_true = 9.81
rng10 = np.random.default_rng(1)
rng40 = np.random.default_rng(1)
trials_10 = [virtual_experiment(0.3, g_true, math.radians(10), 10, 0.02, rng10) for _ in range(300)]
trials_40 = [virtual_experiment(0.3, g_true, math.radians(10), 40, 0.02, rng40) for _ in range(300)]
std_10 = np.std([t.g_measured for t in trials_10])
std_40 = np.std([t.g_measured for t in trials_40])
self.assertLess(std_40, std_10)
def test_measured_g_centers_on_true_g_over_many_trials(self):
rng = np.random.default_rng(7)
trials = [virtual_experiment(0.3, 9.81, math.radians(10), 20, 0.02, rng) for _ in range(500)]
mean_g = np.mean([t.g_measured for t in trials])
self.assertAlmostEqual(mean_g, 9.81, delta=0.05)
if __name__ == "__main__":
unittest.main()