A gravitational N-body simulator written from scratch in Python + NumPy, whose core is a pluggable numerical integrator validated by conservation laws. The thesis of the project: which integrator you use matters more than per-step accuracy — explicit Euler makes orbits spiral out, symplectic integrators conserve energy forever.
The same Sun–Earth orbit (eccentric, e ≈ 0.19), the same step dt, ~400 orbits —
only the integrator changes. The vertical axis is the relative energy drift
- Explicit Euler (red) grows in a secular, monotonic way up to ~91% — every step injects spurious energy into the orbit, which spirals outward. This is what happens in a naive simulator. It is not an implementation bug: it is a structural property of the method.
- Semi-implicit Euler and Leapfrog (orange/blue) are symplectic: the energy error stays bounded and oscillating around the true value, with no accumulated drift — forever. Leapfrog (2nd order) has a much narrower error band than semi-implicit Euler (1st order).
- RK4 (green) is the most accurate in the short term (4th order), but it is not symplectic: a slow secular drift creeps in and, over very long integrations, it eventually loses to the symplectic schemes.
This is the central argument of the project, made visible. The rest of the code exists to back this figure up with rigor.
| Inner system (Sun → Mars) | Full system (out to Neptune) |
|---|---|
![]() |
![]() |
Most "weekend" gravitational simulators use explicit Euler because it is the obvious method: update position with velocity, update velocity with acceleration. The problem is that the orbits spiral — energy grows without bound and the simulation lies about the physics after a few dozen orbits.
This project treats the time integrator as a first-class object of study. The four schemes (explicit Euler, semi-implicit Euler, Leapfrog/Velocity-Verlet, RK4) live behind a common interface and can be swapped in one line — or live, with a keystroke, inside the visualization. This makes it possible to compare symplectic vs. non-symplectic rather than just assert the difference, and to use the conservation laws (energy, linear momentum, angular momentum, center of mass) as an objective correctness criterion.
The result is validated at two milestones: the analytic Sun–Earth system (Milestone 1) and the real Solar System, with NASA JPL Horizons ephemerides at the J2000 epoch (Milestone 2).
The gravitational force on body
Each body is described by position and velocity. Writing the state as
This is exactly the form the integrators receive: a function
accel_fn(pos) -> acc that evaluates
Working in SI carries the measurement uncertainty of
By Kepler's third law, a circular orbit of radius
By definition of our units, the Earth orbits with
Practical consequence: the circular orbital velocity at 1 AU is exactly
src/units.py — no magic
number leaks into the rest of the code.
The Newtonian force
With
The flow of the N-body Hamiltonian system preserves the symplectic structure of phase
space (the Liouville volume, the 2-form
A non-symplectic integrator has no such protection: the energy error accumulates in a secular (monotonic) way, even if each individual step is very accurate.
| Scheme | Global order | Symplectic? | Energy behavior |
|---|---|---|---|
| Explicit Euler | no | secular drift, spirals outward | |
| Semi-implicit Euler | yes | bounded (wide band) | |
| Leapfrog / Velocity-Verlet (default) | yes | bounded and oscillating (narrow band) | |
| RK4 | no | tiny in the short term, slow secular drift |
The difference between explicit and semi-implicit Euler is a single line: the semi-implicit one uses the already updated velocity to move the position. This trivial swap is what makes the scheme symplectic — a perfect textbook example that the structure of the method, not just its order, governs long-term behavior.
Leapfrog in kick-drift-kick form (the default integrator):
The scheme is time-symmetric (reversible) and symplectic; the local error is
In an isolated system, linear momentum src/diagnostics.py and used as validation asserts in
the experiments and in the test suite.
As proof that force and potential are mutually consistent, there is a gradient check:
verifying that $\mathbf{a}i = -\tfrac{1}{m_i}\nabla{\mathbf{r}_i} U$ by central finite
differences. Maximum measured error < 1e-7 in the Newtonian case (
Structure-of-Arrays (SoA) layout, ready for 3D (pos[N,3] (AU), vel[N,3] (AU/yr), mass[N] (M☉). SoA keeps the arrays
contiguous and lets all the physics be expressed via NumPy broadcasting, with no
Python loops over particle pairs.
| Module | Responsibility |
|---|---|
src/units.py |
AU/year/M☉ unit system, |
src/forces.py |
Fully vectorized [N,N,3] by broadcasting) with Plummer softening. |
src/integrators.py |
Pluggable integrator behind a common interface (Integrator.step). Explicit Euler, semi-implicit Euler, Leapfrog (default), RK4. get_integrator(name) selects by name. |
src/diagnostics.py |
Conserved quantities: energy |
src/simulation.py |
Simulation engine (simulate) + State and History. Decoupled from any rendering — it only knows about state, integrator and force law. |
src/initial_conditions.py |
Scenario builders (e.g. sun_earth), with momentum balancing and recentering on the CM. |
src/data_loader.py |
Real Solar System ephemerides via JPL Horizons (astroquery), converted to internal units, .npz cache + built-in offline fallback. |
src/render.py |
2D Pygame visualization. 100% decoupled from the physics — every drawn position comes from the existing engine, it never reimplements gravity. |
The central design is the pluggable integrator. The interface is deliberately minimal:
Integrator.step(pos, vel, mass, dt, accel_fn) -> (pos_new, vel_new)where accel_fn(pos) -> acc[N,3] is a callback. The integrator does not know the force
law, which lets you swap the scheme without touching the physics and drives the
comparative experiment.
pip install -r requirements.txtNumPy and Matplotlib are required. astroquery (Horizons), pygame (visualization),
pillow (GIF export), hypothesis (property-based tests) are optional — the code
degrades gracefully when they are absent (offline fallback, clear messages, skips).
Run everything from the repository root. The scripts also accept direct execution (
python experiments/file.py), butpython -m experiments.fileis the recommended path.
python -m experiments.marco1_two_bodyIntegrates the circular Sun–Earth orbit with Leapfrog, prints the validation numbers
(measured period, Leapfrog vs. Euler energy drift, conservation of P/CM/L) and saves
experiments/energia_marco1.png.
python -m experiments.marco2_solar_systemLoads Sun + 8 planets at the J2000 epoch via Horizons, converts to AU/yr/M☉ and integrates 100 years with Leapfrog. Notable points:
- Cache: the 1st run queries Horizons and writes
data/cache/solar_system_J2000.npz; subsequent runs load from the cache and do not touch the network (useforce_refresh=Trueto re-query). - Offline fallback: without
astroquery/network, it uses approximate embedded J2000 vectors (flagged on stdout; not ephemeris-grade) — the simulation runs anywhere. - Saves
experiments/marco2_solar_system.png(orbits + energy drift) and the trajectory historydata/cache/marco2_trajectory.npzfor the renderer.
python -m experiments.energy_drift_comparisonIntegrates the same eccentric Sun–Earth orbit for ~400 orbits with the four schemes
and plots experiments/energy_drift_comparison.png.
python -m experiments.visualize # live integration (default)
python -m experiments.visualize --mode replay # replay the cached trajectory
python -m experiments.visualize --view full # full system (out to Neptune)
python -m experiments.visualize --integrator rk4 # start with another integratorIn live mode, one engine step per (sub)frame starting from the data_loader state;
switching the integrator with I lets you watch symplectic vs. non-symplectic stability
diverge on the live energy readout in the HUD. Replay mode plays back
data/cache/marco2_trajectory.npz (falls back to live if the cache does not exist).
Controls:
| Key | Action | Key | Action |
|---|---|---|---|
SPACE |
pause / resume | T |
toggle trails |
+ / - |
speed up / slow down time | L |
toggle labels |
I |
cycle integrator (euler→semi→leapfrog→rk4) | C |
clear trails |
V |
toggle inner / full view | R |
reset simulation |
| scroll | zoom | drag | pan |
H |
controls overlay | ESC / Q |
quit |
Headless capture (no display — used to generate the README images):
python -m experiments.visualize --headless --frames 360 --substeps 40 \
--view full --screenshot assets/screenshot_full.png --gif assets/orbits.gifpython -m pytest tests/ -q35 tests. They cover: the core gradient check (force = test_smoke.py also runs as a script: python tests/test_smoke.py.)
All the numbers below are measured by the scripts in this repository.
forces.py and diagnostics.py are mutually consistent.
| Metric | Measured value |
|---|---|
| Orbital period | 1.000021 yr (error 0.002%) |
| Max energy drift $ | \Delta E/E_0 |
| Linear momentum, CM, angular momentum | conserved to machine precision |
| Planet | Measured period | Real period | Error |
|---|---|---|---|
| Mercury | 0.241 | 0.2408 | 0.05% |
| Venus | 0.615 | 0.6152 | 0.002% |
| Earth | 1.000 | 1.0000 | 0.005% |
| Mars | 1.880 | 1.8808 | 0.04% |
Conservation: max
| Integrator | Order | Symplectic | final
35 passed — parametrized conservation (4 integrators), two-body, golden by hash, property-based (Hypothesis), offline loader.
The core is validated; the next milestones are not yet implemented and define the direction of the project:
- Milestone 4 — Interactive sandbox: gravity assist scenarios, Lagrange points, live editing of initial conditions.
-
Milestone 5 — Barnes-Hut
$O(N \log N)$ : octree to scale from dozens to thousands of bodies, beating the$O(N^2)$ bottleneck of the direct sum. - Milestone 6 — Post-Newtonian relativistic correction: 1st-order PN term to reproduce Mercury's perihelion precession (43"/century) — the classic test that pure Newtonian gravity does not capture.
- The force sum is
$O(N^2)$ and materializes the[N,N,3]tensor: great for small N (the current milestones), but infeasible for thousands of bodies until Milestone 5. - Leapfrog does two force evaluations per step; reusing the second as the first of the next step was left out for clarity (pending micro-optimization).
- Fixed time step; there is no adaptive error control nor per-body individual timestep (relevant for systems with very disparate time scales).
- The
data_loaderoffline fallback is approximate (not ephemeris-grade): good for running on any machine, but ephemeris-grade numbers require the Horizons query.
- Ephemeris data: NASA JPL Horizons (via
astroquery), J2000 epoch. - Planetary masses: JPL planetary GM (DE440) / GM☉ — physical constants, not derived from the Horizons state query (which returns kinematics only).
- Licensed under the MIT License.
Portfolio project — Eduardo, Computer Engineering.



