File size: 5,179 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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
"""
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,
}