| import os |
| import tempfile |
| from dataclasses import dataclass, field |
|
|
| import gradio as gr |
| import matplotlib.pyplot as plt |
| import numpy as np |
| from matplotlib.animation import FuncAnimation |
|
|
|
|
| |
| |
| |
| @dataclass |
| class Body: |
| name: str |
| mass: float |
| position: np.ndarray |
| velocity: np.ndarray |
| color: str = "white" |
| size: float = 8.0 |
| acceleration: np.ndarray = field(default_factory=lambda: np.zeros(2, dtype=float)) |
| trail: list = field(default_factory=list) |
|
|
| def __post_init__(self): |
| self.position = np.array(self.position, dtype=float) |
| self.velocity = np.array(self.velocity, dtype=float) |
| self.acceleration = np.array(self.acceleration, dtype=float) |
|
|
| def record_position(self): |
| self.trail.append(self.position.copy()) |
|
|
|
|
| |
| |
| |
| def compute_accelerations(bodies, G=1.0, softening=1e-3): |
| n = len(bodies) |
| accelerations = [np.zeros(2, dtype=float) for _ in range(n)] |
|
|
| for i in range(n): |
| for j in range(n): |
| if i == j: |
| continue |
|
|
| displacement = bodies[j].position - bodies[i].position |
| distance_sq = np.dot(displacement, displacement) + softening**2 |
| distance = np.sqrt(distance_sq) |
|
|
| accelerations[i] += G * bodies[j].mass * displacement / (distance_sq * distance) |
|
|
| return accelerations |
|
|
|
|
| def step_velocity_verlet(bodies, dt, G=1.0, softening=1e-3): |
| old_accelerations = compute_accelerations(bodies, G=G, softening=softening) |
|
|
| for body, acc in zip(bodies, old_accelerations): |
| body.acceleration = acc |
| body.position = body.position + body.velocity * dt + 0.5 * acc * dt**2 |
|
|
| new_accelerations = compute_accelerations(bodies, G=G, softening=softening) |
|
|
| for body, old_acc, new_acc in zip(bodies, old_accelerations, new_accelerations): |
| body.velocity = body.velocity + 0.5 * (old_acc + new_acc) * dt |
| body.acceleration = new_acc |
| body.record_position() |
|
|
|
|
| class SimulationVV: |
| def __init__(self, bodies, dt=0.001, G=1.0, softening=1e-3): |
| self.bodies = bodies |
| self.dt = dt |
| self.G = G |
| self.softening = softening |
| self.time = 0.0 |
|
|
| for body in self.bodies: |
| if len(body.trail) == 0: |
| body.record_position() |
|
|
| def step(self): |
| step_velocity_verlet( |
| self.bodies, |
| dt=self.dt, |
| G=self.G, |
| softening=self.softening, |
| ) |
| self.time += self.dt |
|
|
|
|
| |
| |
| |
| def compute_kinetic_energy(bodies): |
| total_ke = 0.0 |
| for body in bodies: |
| total_ke += 0.5 * body.mass * np.dot(body.velocity, body.velocity) |
| return total_ke |
|
|
|
|
| def compute_potential_energy(bodies, G=1.0, softening=1e-3): |
| total_pe = 0.0 |
| n = len(bodies) |
|
|
| for i in range(n): |
| for j in range(i + 1, n): |
| displacement = bodies[j].position - bodies[i].position |
| distance = np.sqrt(np.dot(displacement, displacement) + softening**2) |
| total_pe += -G * bodies[i].mass * bodies[j].mass / distance |
|
|
| return total_pe |
|
|
|
|
| |
| |
| |
| def make_two_body_system(G=1.0): |
| M = 1000.0 |
| r = 1.0 |
| v = np.sqrt(G * M / r) |
|
|
| sun = Body( |
| name="Sun", |
| mass=M, |
| position=[0.0, 0.0], |
| velocity=[0.0, 0.0], |
| color="gold", |
| size=18, |
| ) |
|
|
| earth = Body( |
| name="Earth", |
| mass=1.0, |
| position=[r, 0.0], |
| velocity=[0.0, v], |
| color="deepskyblue", |
| size=8, |
| ) |
|
|
| return [sun, earth] |
|
|
|
|
| def make_three_body_system(): |
| body1 = Body( |
| name="Body 1", |
| mass=500.0, |
| position=[-0.8, 0.0], |
| velocity=[0.0, -10.0], |
| color="orange", |
| size=14, |
| ) |
|
|
| body2 = Body( |
| name="Body 2", |
| mass=500.0, |
| position=[0.8, 0.0], |
| velocity=[0.0, 10.0], |
| color="cyan", |
| size=14, |
| ) |
|
|
| body3 = Body( |
| name="Body 3", |
| mass=5.0, |
| position=[0.0, 1.2], |
| velocity=[18.0, 0.0], |
| color="magenta", |
| size=8, |
| ) |
|
|
| return [body1, body2, body3] |
|
|
|
|
| def make_custom_two_body(central_mass, orbit_radius, planet_mass, planet_speed, tangential_direction): |
| sun = Body( |
| name="Central Body", |
| mass=central_mass, |
| position=[0.0, 0.0], |
| velocity=[0.0, 0.0], |
| color="gold", |
| size=18, |
| ) |
|
|
| vy = planet_speed if tangential_direction == "Counterclockwise" else -planet_speed |
|
|
| planet = Body( |
| name="Planet", |
| mass=planet_mass, |
| position=[orbit_radius, 0.0], |
| velocity=[0.0, vy], |
| color="deepskyblue", |
| size=8, |
| ) |
|
|
| return [sun, planet] |
|
|
|
|
| |
| |
| |
| def simulate_system( |
| preset, |
| dt, |
| frames, |
| steps_per_frame, |
| G, |
| softening, |
| trail_length, |
| central_mass, |
| orbit_radius, |
| planet_mass, |
| planet_speed, |
| tangential_direction, |
| ): |
| if preset == "Two-Body Circular Orbit": |
| bodies = make_two_body_system(G=G) |
| elif preset == "Three-Body Chaotic System": |
| bodies = make_three_body_system() |
| else: |
| bodies = make_custom_two_body( |
| central_mass=central_mass, |
| orbit_radius=orbit_radius, |
| planet_mass=planet_mass, |
| planet_speed=planet_speed, |
| tangential_direction=tangential_direction, |
| ) |
|
|
| sim = SimulationVV(bodies, dt=dt, G=G, softening=softening) |
|
|
| kinetic_history = [] |
| potential_history = [] |
| total_history = [] |
|
|
| fig, (ax_orbit, ax_energy) = plt.subplots(1, 2, figsize=(14, 6)) |
| fig.patch.set_facecolor("#0f1117") |
| ax_orbit.set_facecolor("black") |
| ax_energy.set_facecolor("white") |
|
|
| max_range = max(2.5, orbit_radius * 1.8) |
| ax_orbit.set_xlim(-max_range, max_range) |
| ax_orbit.set_ylim(-max_range, max_range) |
| ax_orbit.set_aspect("equal") |
| ax_orbit.set_title("Orbital Dynamics", color="white", fontsize=14) |
| ax_orbit.tick_params(colors="white") |
| for spine in ax_orbit.spines.values(): |
| spine.set_color("white") |
| ax_orbit.grid(alpha=0.15, color="white") |
|
|
| ax_energy.set_title("Energy Diagnostics", fontsize=14) |
| ax_energy.set_xlabel("Frame") |
| ax_energy.set_ylabel("Energy") |
| ax_energy.grid(alpha=0.3) |
|
|
| scatters = [] |
| trail_lines = [] |
|
|
| for body in sim.bodies: |
| scatter = ax_orbit.scatter([], [], color=body.color, s=body.size * 18, edgecolors="white", linewidths=0.5) |
| line, = ax_orbit.plot([], [], color=body.color, alpha=0.7, linewidth=1.5) |
| scatters.append(scatter) |
| trail_lines.append(line) |
|
|
| ke_line, = ax_energy.plot([], [], label="Kinetic Energy", linewidth=2) |
| pe_line, = ax_energy.plot([], [], label="Potential Energy", linewidth=2) |
| te_line, = ax_energy.plot([], [], label="Total Energy", linewidth=2) |
| ax_energy.legend() |
|
|
| def init(): |
| for scatter, line in zip(scatters, trail_lines): |
| scatter.set_offsets(np.array([[np.nan, np.nan]])) |
| line.set_data([], []) |
| ke_line.set_data([], []) |
| pe_line.set_data([], []) |
| te_line.set_data([], []) |
| return scatters + trail_lines + [ke_line, pe_line, te_line] |
|
|
| def update(_frame): |
| for _ in range(steps_per_frame): |
| sim.step() |
|
|
| ke = compute_kinetic_energy(sim.bodies) |
| pe = compute_potential_energy(sim.bodies, G=G, softening=softening) |
| kinetic_history.append(ke) |
| potential_history.append(pe) |
| total_history.append(ke + pe) |
|
|
| for i, body in enumerate(sim.bodies): |
| scatters[i].set_offsets(body.position.reshape(1, 2)) |
| trail = np.array(body.trail[-trail_length:]) |
| if len(trail) > 1: |
| trail_lines[i].set_data(trail[:, 0], trail[:, 1]) |
|
|
| x = np.arange(len(total_history)) |
| ke_line.set_data(x, kinetic_history) |
| pe_line.set_data(x, potential_history) |
| te_line.set_data(x, total_history) |
|
|
| ax_energy.relim() |
| ax_energy.autoscale_view() |
|
|
| return scatters + trail_lines + [ke_line, pe_line, te_line] |
|
|
| anim = FuncAnimation( |
| fig, |
| update, |
| frames=frames, |
| init_func=init, |
| interval=40, |
| blit=False, |
| ) |
|
|
| temp_dir = tempfile.mkdtemp() |
| output_path = os.path.join(temp_dir, "nbody_simulation.gif") |
| anim.save(output_path, writer="pillow", fps=20) |
| plt.close(fig) |
|
|
| return output_path |
|
|
|
|
| |
| |
| |
| DESCRIPTION = """ |
| ## N-Body Orbital Physics Lab |
| |
| Explore Newtonian gravity with an interactive **Velocity Verlet** simulation. |
| |
| ### Included |
| - Two-body circular orbit |
| - Three-body chaotic dynamics |
| - Custom two-body experiment mode |
| - Energy diagnostics |
| - Adjustable timestep, softening, and trail length |
| """ |
|
|
| with gr.Blocks(title="N-Body Orbital Physics Lab", theme=gr.themes.Soft()) as demo: |
| gr.Markdown("# 🪐 N-Body Orbital Physics Lab") |
| gr.Markdown(DESCRIPTION) |
|
|
| with gr.Row(): |
| with gr.Column(): |
| preset = gr.Dropdown( |
| choices=[ |
| "Two-Body Circular Orbit", |
| "Three-Body Chaotic System", |
| "Custom Two-Body Experiment", |
| ], |
| value="Two-Body Circular Orbit", |
| label="Preset", |
| ) |
|
|
| dt = gr.Slider(0.0001, 0.005, value=0.0005, step=0.0001, label="Time Step (dt)") |
| frames = gr.Slider(100, 400, value=250, step=50, label="Rendered Frames") |
| steps_per_frame = gr.Slider(1, 8, value=4, step=1, label="Physics Steps per Frame") |
| G = gr.Slider(0.1, 5.0, value=1.0, step=0.1, label="Gravitational Constant (G)") |
| softening = gr.Slider(0.0001, 0.05, value=0.001, step=0.0001, label="Softening") |
| trail_length = gr.Slider(20, 300, value=100, step=10, label="Trail Length") |
|
|
| gr.Markdown("### Custom Two-Body Controls") |
| central_mass = gr.Slider(100, 5000, value=1000, step=50, label="Central Mass") |
| orbit_radius = gr.Slider(0.5, 3.0, value=1.0, step=0.1, label="Orbit Radius") |
| planet_mass = gr.Slider(0.1, 20.0, value=1.0, step=0.1, label="Planet Mass") |
| planet_speed = gr.Slider(1.0, 80.0, value=31.6, step=0.1, label="Planet Tangential Speed") |
| tangential_direction = gr.Radio( |
| choices=["Counterclockwise", "Clockwise"], |
| value="Counterclockwise", |
| label="Orbit Direction", |
| ) |
|
|
| run_button = gr.Button("Run Simulation", variant="primary") |
|
|
| with gr.Column(): |
| output_gif = gr.Image(label="Simulation Output", type="filepath") |
|
|
| run_button.click( |
| fn=simulate_system, |
| inputs=[ |
| preset, |
| dt, |
| frames, |
| steps_per_frame, |
| G, |
| softening, |
| trail_length, |
| central_mass, |
| orbit_radius, |
| planet_mass, |
| planet_speed, |
| tangential_direction, |
| ], |
| outputs=output_gif, |
| ) |
|
|
| if __name__ == "__main__": |
| demo.launch() |