Spaces:
Runtime error
Runtime error
| """ | |
| 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() | |