Keplerian Motion vs. N-body

Keplerian Motion vs. N-body#

Pure two-body (Keplerian) propagation is orders of magnitude faster than a full N-body integration, and it can be a good approximation over short timescales. But how short is “short”? The answer depends on where an object lives in the solar system.

In this tutorial, we compare Keplerian and N-body ephemerides for representative objects from four populations:

Population

Example object

Semi-major axis

Near-Earth Object

(433) Eros

~1.5 AU

Main Belt

(6) Hebe

~2.4 AU

Jupiter Trojan

(624) Hektor

~5.2 AU

Trans-Neptunian Object

(15760) Albion

~43 AU

import jax

jax.config.update("jax_enable_x64", True)
import jax.numpy as jnp

import astropy.units as u
import matplotlib.pyplot as plt
import numpy as np
from astropy.time import Time

from jorbit import Particle

Setup#

We create two Particle objects for each body: one using gravity="keplerian" (pure two-body) and one using the full gravity="default solar system" model (PPN planets + 16 massive asteroids). We then compare their on-sky positions over 5 years.

epoch = Time("2025-01-01")
days = jnp.arange(1, 365 * 5, 5)  # every 5 days for 5 years
times = Time(days + epoch.tdb.jd, format="jd", scale="tdb")

objects = {
    "(1864) Daedalus\n(NEO, a$\\approx$1.5 AU)": "1864",
    "(274301) Wikipedia\n(Main Belt, a$\\approx$2.4 AU)": "274301",
    "(624) Hektor\n(Trojan, a$\\approx$5.2 AU)": "624",
    "(15760) Albion\n(TNO, a$\\approx$43 AU)": "15760",
}
separations = {}
for label, name in objects.items():
    p_kep = Particle.from_horizons(name=name, time=epoch, gravity="keplerian")
    p_nb = Particle.from_horizons(name=name, time=epoch, gravity="default solar system")

    eph_kep = p_kep.ephemeris(times, observer="kitt peak")
    eph_nb = p_nb.ephemeris(times, observer="kitt peak")

    observable_mask_kep = p_kep.is_observable(
        times, observer="kitt peak", sun_limit=20.0, ephem=eph_kep
    )
    observable_mask_nb = p_nb.is_observable(
        times, observer="kitt peak", sun_limit=20.0, ephem=eph_nb
    )
    observable_mask = observable_mask_kep & observable_mask_nb

    seps = eph_kep.separation(eph_nb).to(u.arcsec).value
    seps[~observable_mask] = np.nan

    separations[label] = seps
    print(f"{label.split(chr(10))[0]} done")
(1864) Daedalus done
(274301) Wikipedia done
(624) Hektor done
(15760) Albion done

Comparison#

Let’s plot the on-sky separation between the Keplerian and N-body ephemerides for each object. The dashed lines mark 1 arcsecond and 1 arcminute for reference. Note that times when the objects were within 20 degrees of the Sun are masked out by our observability mask.

fig, ax = plt.subplots(figsize=(10, 6))

for label, sep in separations.items():
    ax.plot(days, sep, label=label)

ax.axhline(1, color="gray", ls="--", lw=0.8, label='1"')
ax.axhline(60, color="gray", ls=":", lw=0.8, label="1'")
ax.axhline(60 * 60, color="gray", ls="-.", lw=0.8, label="1°")

ax.set(
    xlabel="Days since epoch",
    ylabel="Keplerian - N-body separation [arcsec]",
    yscale="log",
    xscale="log",
)
ax.legend(fontsize=9)
fig.tight_layout()
../_images/251f5b30bee6e93b086faa7463acc2e8991c288b0c510193c568b13667b773ca.png

Discussion#

A few takeaways:

  • NEOs experience strong planetary perturbations and can diverge from Keplerian motion by arcseconds within days.

  • Main-belt asteroids are constantly perturbed by Jupiter. The Keplerian approximation breaks down at the arcsecond level within a few weeks.

  • Jupiter Trojans are slightly safer, but still show >1 arcsecond divergence within a few months.

  • TNOs are the most weakly perturbed — their large heliocentric distances mean planetary perturbations are small. Keplerian motion can remain accurate to sub-arcsecond levels for several years.

For quick-look ephemerides or initial orbit determination, Keplerian propagation (via gravity="keplerian") is fast and convenient. For precision work — orbit fitting, observation planning, or astrometric comparison — the full N-body model should be used.

Batched Keplerian with System#

When you need Keplerian ephemerides for many objects at once, the System class can evaluate them in a single vectorized call. This is fully vmapped (no sequential loops over particles or times), making it efficient for large batches.

from jorbit.system import System

particles = [
    Particle.from_horizons(name=name, time=epoch, gravity="keplerian")
    for name in objects.values()
]
sys = System(particles=particles, gravity="keplerian")

eph = sys.ephemeris(times, observer="kitt peak")
print(f"Ephemeris shape: {eph.ra.shape}  (N_particles, N_times)")
Ephemeris shape: (4, 365)  (N_particles, N_times)
# positions and velocities are also available
positions, velocities = sys.integrate(times)
print(f"Positions shape: {positions.shape}  (N_times, N_particles, 3)")
Positions shape: (365, 4, 3)  (N_times, N_particles, 3)