File size: 5,441 Bytes
72ae1e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
"""
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()