Orbits#
Two bodies: a spacecraft state, written once, and a leapfrog step
Teach hawk what a spacecraft state is once, then write the propagation step in those terms.
Time: ~5 min · Runs on: CPU (same code on GPU) · You need: hawk, NumPy and matplotlib; read first: hawk in your domain
Colab or Kaggle: run this first cell (it installs the packages and downloads the helper files this notebook uses). Elsewhere it only checks the imports.
The state and the force, once#
A two-body orbit needs a position, a velocity, a time step and a gravitational
parameter mu. Declare them as a Kind; every orbit kernel then starts from
those names. Units here are scaled so that mu = 1 and a circular orbit of
radius 1 has period 2π.
import numpy as np
import hawk, hawk.artifact
from hawk import Mutable, Param, Vector
from hawk.ext import DATA_ONLY, KernelKind
from hawk.math import norm
class Orbit(KernelKind, slug="orbit_body"):
r: Vector[3]
v: Vector[3]
dt: Param
mu: Param
guard = DATA_ONLY
Orbit
__main__.Orbit
One step, in your terms#
A leapfrog step (half kick, drift, half kick) of the two-body force
-mu r / |r|^3. The body reads like the physics:
@Orbit
def leapfrog(r, v, dt, mu, r_next: Mutable[Vector[3]], v_next: Mutable[Vector[3]]):
v_half = v + 0.5 * dt * (-mu * r / norm(r) ** 3)
r_new = r + dt * v_half
r_next = r_new
v_next = v_half + 0.5 * dt * (-mu * r_new / norm(r_new) ** 3)
leapfrog
<Kernel leapfrog slots=6>
A handful of orbits#
Four orbits that share a periapsis at r = 1 and differ in eccentricity,
from circular to e = 0.7. Each is one sample; one call advances all four,
and we run five revolutions of the longest one.
import pathlib, tempfile
work_dir = pathlib.Path(tempfile.mkdtemp())
hawk.artifact.build(leapfrog, work_dir, targets=("host",))
step = hawk.load(work_dir, "leapfrog")
mu = 1.0
ecc = np.array([0.0, 0.2, 0.5, 0.7])
n = len(ecc)
r = np.zeros((3, n)); r[0] = 1.0 # periapsis on the x axis
v = np.zeros((3, n)); v[1] = np.sqrt(mu * (1 + ecc)) # periapsis speed
semi_major = 1.0 / (1.0 - ecc)
period = 2 * np.pi * semi_major ** 1.5
dt = 2 * np.pi / 1000
n_steps = int(np.ceil(5 * period.max() / dt))
def energy(r, v):
return 0.5 * (v * v).sum(axis=0) - mu / np.sqrt((r * r).sum(axis=0))
e0 = energy(r, v)
drift = np.zeros(n)
path = [r.copy()]
r_next, v_next = np.zeros_like(r), np.zeros_like(v)
for i in range(n_steps):
hawk.run(step, r=r, v=v, dt=dt, mu=mu, r_next=r_next, v_next=v_next)
r, r_next = r_next, r
v, v_next = v_next, v
drift = np.maximum(drift, np.abs(energy(r, v) / e0 - 1.0))
if i % 20 == 0:
path.append(r.copy())
path = np.array(path)
print(n_steps, "steps; worst relative energy change per orbit:", drift)
30430 steps; worst relative energy change per orbit: [3.89653532e-10 5.26999417e-06 1.34112539e-05 2.75801903e-05]
Check it against what is known#
Two-body motion conserves energy. A leapfrog step does not conserve it exactly, but it keeps it within a small band, so the check is a tolerance, not an equality. A second check, with a known answer: a circular orbit of radius 1 comes back to its start after one period, 2π.
TOL_ENERGY = 1e-4
assert drift.max() < TOL_ENERGY, drift
print(f"energy: worst relative change {drift.max():.1e} < {TOL_ENERGY:g}")
c_r = np.array([[1.0], [0.0], [0.0]])
c_v = np.array([[0.0], [1.0], [0.0]])
c_next, cv_next = np.zeros_like(c_r), np.zeros_like(c_v)
for _ in range(1000): # 1000 steps of 2*pi/1000 = one period
hawk.run(step, r=c_r, v=c_v, dt=dt, mu=mu, r_next=c_next, v_next=cv_next)
c_r, c_next = c_next, c_r
c_v, cv_next = cv_next, c_v
TOL_PERIOD = 1e-3
miss = np.abs(c_r[:, 0] - [1.0, 0.0, 0.0]).max()
assert miss < TOL_PERIOD, miss
print(f"circular orbit after one period: off by {miss:.1e} < {TOL_PERIOD:g}")
energy: worst relative change 2.8e-05 < 0.0001
circular orbit after one period: off by 8.3e-05 < 0.001
The four orbits, five revolutions of the longest one:
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(4.5, 3.6), constrained_layout=True)
for j in range(n):
ax.plot(path[:, 0, j], path[:, 1, j], lw=1.2, label=f"e = {ecc[j]:g}")
ax.plot([0], [0], "o", color="#eda100", ms=7, label="central body")
ax.set_aspect("equal"); ax.set_xlabel("x"); ax.set_ylabel("y")
ax.legend(frameon=False, fontsize=8, loc="upper left", bbox_to_anchor=(1.02, 1.0))
ax.set_title("four orbits, one kernel")
show()
What just happened#
Orbitnamed the state (r,v) and the constants (dt,mu) once.leapfrogis the step written in those names; hawk compiled it for the host, and the same source builds for a GPU.Energy stayed within the stated tolerance and the circular orbit closed on itself, so the kernel does what the physics says.
Try this#
Add a j2: Param to a second @Orbit model and a small extra acceleration
in z; the shared r, v, dt, mu stay as they are
(grow your vocabulary). Because the kernel is
ordinary hawk, hawk.diff.vjp also gives the derivative of the final state with
respect to the start (supply derivatives).
Next#
Capstone: your physics, end to end runs a full fit at scale.