dschechter27's picture
Create app.py
f123e58 verified
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
# -----------------------------
# Core physics data structures
# -----------------------------
@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())
# -----------------------------
# Physics engine
# -----------------------------
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
# -----------------------------
# Diagnostics
# -----------------------------
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
# -----------------------------
# Presets
# -----------------------------
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]
# -----------------------------
# Rendering
# -----------------------------
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
# -----------------------------
# Gradio UI
# -----------------------------
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()