| | 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() |