Spaces:
Runtime error
Runtime error
| """ | |
| app.py -- interactive nonlinear pendulum simulator (Gradio). | |
| No camera, no hardware, no OpenCV: this is the browser-accessible, | |
| "anyone-anywhere" half of the pendulum-gravity-lab project. It is a | |
| numerical companion to vision_lab/pendulum_tracker.py -- same physics, | |
| same measurement method (time N oscillations -> g = 4*pi^2*L/T^2), but | |
| run on an exact numerical integration instead of a webcam, so it works | |
| identically on a laptop, a phone browser, or a Hugging Face Space. | |
| """ | |
| from __future__ import annotations | |
| import io | |
| import math | |
| import gradio as gr | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| import numpy as np | |
| from matplotlib.patches import Circle | |
| from PIL import Image | |
| from pendulum_physics import ( | |
| PLANETS, | |
| period_exact, | |
| period_series_correction, | |
| period_small_angle, | |
| simulate, | |
| virtual_experiment, | |
| ) | |
| # --- Hugging Face ZeroGPU free-tier shim ----------------------------------- | |
| # This app is pure CPU (numpy/scipy RK4 + matplotlib); it needs no GPU at | |
| # all. But HF's free tier can force newly created Gradio Spaces onto | |
| # ZeroGPU hardware, which otherwise requires a paid PRO plan to downgrade | |
| # to CPU-basic. The documented workaround (see PROJECT-PUBLISHING-CONTEXT): | |
| # declare *one* dummy function with @spaces.GPU so the Space is accepted on | |
| # the free tier, but NEVER decorate a function that contains real logic -- | |
| # a crash inside a @spaces.GPU function runs in a separate worker process | |
| # whose exceptions never reach a local try/except, making it very hard to | |
| # debug. `spaces` only exists inside the HF runtime, hence the import guard. | |
| try: | |
| import spaces | |
| def _zerogpu_keepalive() -> None: | |
| """Intentionally empty -- see module note above.""" | |
| return None | |
| except ImportError: | |
| spaces = None | |
| def _zerogpu_keepalive() -> None: | |
| return None | |
| # --------------------------------------------------------------------------- | |
| def plot_pendulum_animation(length_cm: float, amplitude_deg: float, gravity: float, damping: float): | |
| """Render pendulum visualization with camera view, theta(t), and phase space.""" | |
| L = length_cm / 100.0 | |
| theta0 = math.radians(amplitude_deg) | |
| res = simulate(theta0, gravity, L, damping=damping, t_max=3.0, dt=0.01) | |
| fig, axes = plt.subplots(1, 3, figsize=(16, 5)) | |
| # --- Subplot 1: Pendulum Camera View --- | |
| ax1 = axes[0] | |
| ax1.set_xlim(-0.5, 0.5) | |
| ax1.set_ylim(-0.4, 0.1) | |
| ax1.set_aspect('equal') | |
| ax1.invert_yaxis() | |
| ax1.set_facecolor('#e6d5cc') | |
| ax1.set_title('Camera Feed (Vision Tracker)', fontsize=12, fontweight='bold') | |
| # Draw pivot | |
| ax1.plot(0, 0, 'ro', markersize=12, label='Pivot') | |
| # Draw string and bob at current position | |
| theta_current = res["theta"][-1] | |
| x = L * np.sin(theta_current) | |
| y = L * np.cos(theta_current) | |
| ax1.plot([0, x], [0, y], 'b-', linewidth=2) | |
| # Draw bob | |
| bob = Circle((x, y), 0.03, color='white', ec='blue', linewidth=2) | |
| ax1.add_patch(bob) | |
| # Draw reference line | |
| ax1.axvline(0, color='red', linestyle='--', alpha=0.5, linewidth=1) | |
| # Add angle annotation | |
| if abs(theta_current) > 0.01: | |
| angle_arc = np.linspace(0, theta_current, 30) | |
| arc_r = 0.1 | |
| ax1.plot(arc_r * np.sin(angle_arc), arc_r * np.cos(angle_arc), 'g--', linewidth=1) | |
| ax1.text(0.15, -0.05, f'θ = {np.degrees(theta_current):.1f}°', fontsize=10, color='green') | |
| ax1.legend(loc='upper right', fontsize=9) | |
| ax1.set_xlabel('X (m)', fontsize=10) | |
| ax1.set_ylabel('Y (m)', fontsize=10) | |
| ax1.grid(True, alpha=0.3) | |
| # --- Subplot 2: θ(t) vs Time --- | |
| ax2 = axes[1] | |
| ax2.plot(res["t"], np.degrees(res["theta"]), 'b-', linewidth=2, label='Exact (RK4)') | |
| ax2.plot(res["t"], np.degrees(res["theta_small_angle"]), '--', color='#dc2626', linewidth=1.5, label='Small-angle (SHM)') | |
| ax2.set_xlabel('Time (s)', fontsize=10) | |
| ax2.set_ylabel('Angle θ (degrees)', fontsize=10) | |
| ax2.set_title('Angular Position', fontsize=12, fontweight='bold') | |
| ax2.grid(True, alpha=0.3) | |
| ax2.legend(fontsize=9) | |
| # --- Subplot 3: Phase Space (θ vs ω) --- | |
| ax3 = axes[2] | |
| ax3.plot(np.degrees(res["theta"]), res["omega"], 'r-', linewidth=2, alpha=0.7) | |
| ax3.set_xlabel('Angle θ (degrees)', fontsize=10) | |
| ax3.set_ylabel('Angular Velocity ω (rad/s)', fontsize=10) | |
| ax3.set_title('Phase Space', fontsize=12, fontweight='bold') | |
| ax3.grid(True, alpha=0.3) | |
| plt.tight_layout() | |
| # Convert to image | |
| buf = io.BytesIO() | |
| plt.savefig(buf, format='png', dpi=100, bbox_inches='tight') | |
| buf.seek(0) | |
| img = Image.open(buf) | |
| plt.close() | |
| # Compute summary | |
| T0 = period_small_angle(gravity, L) | |
| Tex = period_exact(theta0, gravity, L) | |
| diff_pct = (Tex / T0 - 1.0) * 100 | |
| summary = ( | |
| f"**T (small-angle) = {T0:.5f} s** — **T (exact) = {Tex:.5f} s** " | |
| f"— الفرق / difference: **{diff_pct:+.3f}%**\n\n" | |
| f"عند سعة {amplitude_deg:.1f}°، خطأ تقريب الزاوية الصغيرة " | |
| f"(SHM) هو {diff_pct:+.3f}% — هذا بالضبط سبب تحديد تجربة الكاميرا الحقيقية " | |
| f"(`vision_lab/`) لزاوية إطلاق لا تتجاوز 15°.\n\n" | |
| f"At {amplitude_deg:.1f}° amplitude, the small-angle (SHM) approximation error is " | |
| f"{diff_pct:+.3f}% — exactly why the real camera experiment (`vision_lab/`) " | |
| f"restricts the release angle to <= 15°." | |
| ) | |
| return img, summary | |
| def plot_motion(length_cm: float, amplitude_deg: float, gravity: float, damping: float, duration_s: float): | |
| L = length_cm / 100.0 | |
| theta0 = math.radians(amplitude_deg) | |
| res = simulate(theta0, gravity, L, damping=damping, t_max=duration_s, dt=min(0.002, duration_s / 2000)) | |
| T0 = period_small_angle(gravity, L) | |
| Tex = period_exact(theta0, gravity, L) | |
| fig, axes = plt.subplots(1, 3, figsize=(15, 4.2)) | |
| ax = axes[0] | |
| ax.plot(res["t"], np.degrees(res["theta"]), label="exact nonlinear (RK4)", color="#2563eb") | |
| ax.plot(res["t"], np.degrees(res["theta_small_angle"]), "--", label="small-angle (SHM)", color="#dc2626", alpha=0.8) | |
| ax.set_xlabel("t (s)") | |
| ax.set_ylabel("theta (deg)") | |
| ax.set_title(f"theta(t) T_exact={Tex:.4f}s T_SHM={T0:.4f}s") | |
| ax.legend(fontsize=8) | |
| ax.grid(alpha=0.3) | |
| ax = axes[1] | |
| ax.plot(np.degrees(res["theta"]), res["omega"], color="#059669") | |
| ax.set_xlabel("theta (deg)") | |
| ax.set_ylabel("omega (rad/s)") | |
| ax.set_title("Phase portrait") | |
| ax.grid(alpha=0.3) | |
| ax = axes[2] | |
| ax.plot(res["t"], res["energy"], color="#7c3aed") | |
| ax.set_xlabel("t (s)") | |
| ax.set_ylabel("specific energy (J/kg)") | |
| ax.set_title("Energy vs time" + (" (damped: should decay)" if damping > 0 else " (undamped: should be flat)")) | |
| ax.grid(alpha=0.3) | |
| fig.tight_layout() | |
| diff_pct = (Tex / T0 - 1.0) * 100 | |
| summary = ( | |
| f"**T (small-angle) = {T0:.5f} s** — **T (exact) = {Tex:.5f} s** " | |
| f"— الفرق / difference: **{diff_pct:+.3f}%**\n\n" | |
| f"عند سعة {amplitude_deg:.1f}°، خطأ تقريب الزاوية الصغيرة " | |
| f"(SHM) هو {diff_pct:+.3f}% — هذا بالضبط سبب تحديد تجربة الكاميرا الحقيقية " | |
| f"(`vision_lab/`) لزاوية إطلاق لا تتجاوز 15°.\n\n" | |
| f"At {amplitude_deg:.1f}° amplitude, the small-angle (SHM) approximation error is " | |
| f"{diff_pct:+.3f}% — exactly why the real camera experiment (`vision_lab/`) " | |
| f"restricts the release angle to <= 15°." | |
| ) | |
| return fig, summary | |
| def plot_period_vs_amplitude(length_cm: float, gravity: float): | |
| L = length_cm / 100.0 | |
| amps_deg = np.linspace(0.5, 175, 200) | |
| T0 = period_small_angle(gravity, L) | |
| exact = [period_exact(math.radians(a), gravity, L) for a in amps_deg] | |
| series = [T0 * (1 + period_series_correction(math.radians(a))) for a in amps_deg] | |
| fig, ax = plt.subplots(figsize=(8, 5)) | |
| ax.axhline(T0, color="#9ca3af", linestyle=":", label=f"small-angle limit T0={T0:.4f}s") | |
| ax.plot(amps_deg, exact, color="#2563eb", label="exact (elliptic integral)") | |
| ax.plot(amps_deg, series, "--", color="#dc2626", label="leading-order series estimate") | |
| ax.axvline(15, color="#059669", linestyle="-.", alpha=0.7, label="15° (vision_lab experimental limit)") | |
| ax.set_xlabel("release amplitude theta_0 (deg)") | |
| ax.set_ylabel("period T (s)") | |
| ax.set_title(f"Period vs amplitude, L={length_cm:.1f} cm, g={gravity:.3f} m/s²") | |
| ax.legend(fontsize=9) | |
| ax.grid(alpha=0.3) | |
| fig.tight_layout() | |
| return fig | |
| def run_virtual_experiment(length_cm: float, gravity: float, amplitude_deg: float, | |
| n_oscillations: int, timing_noise_ms: float, n_trials: int, seed: int): | |
| L = length_cm / 100.0 | |
| theta0 = math.radians(amplitude_deg) | |
| rng = np.random.default_rng(int(seed)) | |
| trials = [ | |
| virtual_experiment(L, gravity, theta0, int(n_oscillations), timing_noise_ms / 1000.0, rng) | |
| for _ in range(int(n_trials)) | |
| ] | |
| g_vals = np.array([t.g_measured for t in trials]) | |
| fig, ax = plt.subplots(figsize=(7, 4.5)) | |
| ax.hist(g_vals, bins=max(5, int(n_trials) // 3), color="#2563eb", alpha=0.75, edgecolor="white") | |
| ax.axvline(gravity, color="#dc2626", linewidth=2, label=f"true g = {gravity:.4f}") | |
| ax.axvline(g_vals.mean(), color="#059669", linewidth=2, linestyle="--", label=f"mean measured = {g_vals.mean():.4f}") | |
| ax.set_xlabel("measured g (m/s²)") | |
| ax.set_ylabel("count") | |
| ax.set_title(f"{int(n_trials)} virtual trials, {int(n_oscillations)} oscillations each") | |
| ax.legend(fontsize=9) | |
| ax.grid(alpha=0.3) | |
| fig.tight_layout() | |
| summary = ( | |
| f"**g الحقيقي / true g = {gravity:.5f} m/s²**\n\n" | |
| f"**متوسط {int(n_trials)} تجربة افتراضية / mean of {int(n_trials)} virtual trials " | |
| f"= {g_vals.mean():.4f} ± {g_vals.std(ddof=1):.4f} m/s²**\n\n" | |
| f"هذه تجربة رقمية توأم لِـ `vision_lab/pendulum_tracker.py` — نفس طريقة " | |
| f"القياس (توقيت {int(n_oscillations)} أرجحة ثم `g=4π²L/T²`) لكن على بندول " | |
| f"معروف تمامًا (بدلًا من كاميرا)، لإظهار حجم الخطأ الذي يفرضه ضجيج التوقيت وحده.\n\n" | |
| f"This is a digital twin of `vision_lab/pendulum_tracker.py` -- same " | |
| f"measurement method, but on a pendulum whose true g is known exactly, " | |
| f"isolating how much scatter timing noise *alone* injects into the result." | |
| ) | |
| return fig, summary | |
| with gr.Blocks(title="مختبر البندول التفاعلي | Interactive Pendulum Lab") as demo: | |
| gr.Markdown( | |
| "# 🔬 مختبر البندول التفاعلي — محاكاة بلا حاجة لكاميرا\n" | |
| "## Interactive Pendulum Lab — no camera required\n" | |
| "نسخة رقمية من تجربة `vision_lab/` الحقيقية القائمة على الرؤية الحاسوبية، " | |
| "تحل معادلة البندول اللاخطية عدديًا (RK4) وتقارنها بتقريب الزاوية الصغيرة. " | |
| "A numerical twin of the real camera-based `vision_lab/` experiment: solves the " | |
| "*exact* nonlinear pendulum equation (RK4) and compares it against the small-angle approximation." | |
| ) | |
| with gr.Tab("رسم البندول | Pendulum Animation"): | |
| gr.Markdown( | |
| "شاهد رسماً بصرياً للبندول مع مسار حركته وفضاء الطور.\n\n" | |
| "Watch a visual rendering of the pendulum with its trajectory and phase portrait." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| length_cm_anim = gr.Slider(5, 150, value=30, step=1, label="طول الخيط L (cm) | String length") | |
| amplitude_deg_anim = gr.Slider(1, 170, value=15, step=1, label="زاوية الإطلاق θ₀ (deg) | Release angle") | |
| planet_anim = gr.Dropdown(list(PLANETS.keys()), value="Earth / الأرض", label="الجاذبية | Gravity (planet)") | |
| damping_anim = gr.Slider(0.0, 3.0, value=0.0, step=0.05, label="التخامد b | Damping coefficient") | |
| run_btn_anim = gr.Button("شغّل | Run", variant="primary") | |
| with gr.Column(scale=2): | |
| plot_out_anim = gr.Image(label="Camera Feed & Analysis") | |
| text_out_anim = gr.Markdown() | |
| def _run_anim(l, a, p, d): | |
| return plot_pendulum_animation(l, a, PLANETS[p], d) | |
| run_btn_anim.click(_run_anim, [length_cm_anim, amplitude_deg_anim, planet_anim, damping_anim], [plot_out_anim, text_out_anim]) | |
| with gr.Tab("المحاكاة الحية | Live Simulation"): | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| length_cm = gr.Slider(5, 150, value=30, step=1, label="طول الخيط L (cm) | String length") | |
| amplitude_deg = gr.Slider(1, 170, value=15, step=1, label="زاوية الإطلاق θ₀ (deg) | Release angle") | |
| planet = gr.Dropdown(list(PLANETS.keys()), value="Earth / الأرض", label="الجاذبية | Gravity (planet)") | |
| damping = gr.Slider(0.0, 3.0, value=0.0, step=0.05, label="التخامد b | Damping coefficient") | |
| duration = gr.Slider(2, 30, value=10, step=1, label="المدة (s) | Duration") | |
| run_btn = gr.Button("شغّل المحاكاة | Run simulation", variant="primary") | |
| with gr.Column(scale=2): | |
| plot_out = gr.Plot() | |
| text_out = gr.Markdown() | |
| def _run(l, a, p, d, dur): | |
| return plot_motion(l, a, PLANETS[p], d, dur) | |
| run_btn.click(_run, [length_cm, amplitude_deg, planet, damping, duration], [plot_out, text_out]) | |
| with gr.Tab("دقة التقريب التوافقي | Small-Angle Accuracy"): | |
| gr.Markdown( | |
| "لماذا يقيّد `vision_lab/` زاوية الإطلاق بـ 15°؟ هذا الرسم يقارن الفترة " | |
| "الزمنية الحقيقية (تكامل إهليلجي) بفترة التقريب البسيط عبر مدى كامل من الزوايا.\n\n" | |
| "Why does `vision_lab/` restrict release angle to 15°? This plot compares the " | |
| "true nonlinear period (elliptic integral) to the small-angle approximation " | |
| "across the full angle range." | |
| ) | |
| with gr.Row(): | |
| length_cm2 = gr.Slider(5, 150, value=30, step=1, label="L (cm)") | |
| planet2 = gr.Dropdown(list(PLANETS.keys()), value="Earth / الأرض", label="Gravity") | |
| plot_btn2 = gr.Button("ارسم | Plot", variant="primary") | |
| plot_out2 = gr.Plot() | |
| plot_btn2.click(lambda l, p: plot_period_vs_amplitude(l, PLANETS[p]), [length_cm2, planet2], plot_out2) | |
| with gr.Tab("التجربة الافتراضية | Virtual Experiment"): | |
| gr.Markdown( | |
| "توأم رقمي لـ `vision_lab/pendulum_tracker.py`: نفس طريقة القياس (20 أرجحة ثم " | |
| "`g=4π²L/T²`)، لكن على بندول معروف الجاذبية تمامًا، لعزل أثر ضجيج التوقيت وحده.\n\n" | |
| "A digital twin of `vision_lab/pendulum_tracker.py`: same measurement method, " | |
| "on a pendulum with an exactly-known g, isolating timing-noise scatter alone." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| length_cm3 = gr.Slider(5, 150, value=30, step=1, label="L (cm)") | |
| planet3 = gr.Dropdown(list(PLANETS.keys()), value="Earth / الأرض", label="True gravity") | |
| amplitude_deg3 = gr.Slider(1, 30, value=15, step=1, label="θ₀ (deg)") | |
| n_osc = gr.Slider(5, 40, value=20, step=1, label="عدد الأرجحات | Oscillations timed") | |
| noise_ms = gr.Slider(0, 100, value=20, step=1, label="ضجيج التوقيت (ms) | Timing noise") | |
| n_trials = gr.Slider(10, 500, value=100, step=10, label="عدد التجارب | Number of trials") | |
| seed = gr.Number(value=42, label="Random seed", precision=0) | |
| run_btn3 = gr.Button("شغّل | Run", variant="primary") | |
| with gr.Column(scale=2): | |
| plot_out3 = gr.Plot() | |
| text_out3 = gr.Markdown() | |
| def _run3(l, p, a, n, noise, trials, sd): | |
| return run_virtual_experiment(l, PLANETS[p], a, n, noise, trials, sd) | |
| run_btn3.click(_run3, [length_cm3, planet3, amplitude_deg3, n_osc, noise_ms, n_trials, seed], [plot_out3, text_out3]) | |
| gr.Markdown( | |
| "---\n" | |
| "**Ahmed Darwish** · [GitHub](https://github.com/eahmeddarwish) · " | |
| "[eahmeddarwish@gmail.com](mailto:eahmeddarwish@gmail.com) \n" | |
| "هذا مشروع تعليمي/بحثي؛ الجزء المعملي الحقيقي (كاميرا + بندول فعلي) في `vision_lab/` " | |
| "بنفس المستودع. | Educational/research project; the real camera-based lab tool lives " | |
| "in `vision_lab/` in the same repository." | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() | |