SGP4/SDP4 satellite propagation backed by the astroz Zig library, using SIMD (AVX512/AVX2) and threads. Near-earth and deep-space satellites are both handled.
Wheels: Linux (x86_64, arm64) and macOS (arm64), Python 3.10 to 3.14.
astroz.api follows the python-sgp4 API:
from astroz.api import Satrec, SatrecArray, jday, WGS72
import numpy as np
# Single satellite (same syntax as python-sgp4)
sat = Satrec.twoline2rv(line1, line2, WGS72)
jd, fr = jday(2024, 5, 6, 12, 0, 0.0)
error, position, velocity = sat.sgp4(jd, fr)
# Many satellites at once
sat_array = SatrecArray(satrecs)
e, r, v = sat_array.sgp4(jd, fr) # Scalars or arrays
# Skip velocities if you only need positions (about 30% faster)
e, r, _ = sat_array.sgp4(jd_array, fr_array, velocities=False)from astroz import propagate
import numpy as np
positions = propagate("starlink", np.arange(1440)) # 1 day at 1-min intervals
# positions: (1440, num_satellites, 3) in km, ECEF coordinatesfrom astroz import propagate, Constellation
# CelesTrak groups
positions = propagate("starlink", times)
positions = propagate("iss", times)
positions = propagate("gps", times)
positions = propagate("all", times) # ~13k active satellites
# By NORAD ID
positions = propagate(None, times, norad_id=25544) # ISS
positions = propagate(None, times, norad_id=[25544, 48274]) # Multiple
# Local file or URL (TLE or OMM JSON auto-detected)
positions = propagate("satellites.tle", times)
positions = propagate("https://example.com/tles.txt", times)
# OMM JSON (CCSDS 502.0-B-3) — supports 6+ digit NORAD IDs
positions = propagate('{"NORAD_CAT_ID":25544,"EPOCH":"2026-04-15T12:00:00.000000","MEAN_MOTION":15.489,...}', times)
# For repeated propagation, pre-parse to avoid overhead
c = Constellation("starlink")
positions1 = propagate(c, times1)
positions2 = propagate(c, times2)Groups: all, starlink, oneweb, planet, spire, gps, glonass, galileo, beidou, stations/iss, weather, geo
from astroz import propagate, Constellation
from datetime import datetime, timezone
import numpy as np
times = np.arange(1440) # 1 day at 1-min intervals
# Simple (defaults: now UTC, ECEF output)
positions = propagate("starlink", times)
# With options
positions = propagate(
"starlink",
np.arange(14 * 1440), # 2 weeks
start_time=datetime(2024, 6, 1, tzinfo=timezone.utc),
output="geodetic", # "ecef" (default), "teme", or "geodetic"
)
# With velocities
positions, velocities = propagate("starlink", times, velocities=True)from astroz import screen
events = screen("starlink", 1440, threshold=5.0) # one day, 5 km
for i, j, t, d in zip(events.i, events.j, events.time, events.distance):
print(f"{i} and {j} pass {d:.3f} km apart at t+{t:.1f} min")
# Only encounters involving satellite 0
mine = screen("starlink", 1440, threshold=5.0, target=0)screen returns every closest approach within the threshold, with its exact
time, miss distance (km) and relative speed (km/s). Satellites are sampled on a
grid (step, 1 minute by default), but each pair is checked with a bound on
how far it can move between samples, so encounters between grid points are not
missed. A coarser step is faster and still finds every encounter.
Changing the import is enough for existing code to run:
# Before
from sgp4.api import Satrec, SatrecArray, jday, WGS72
from sgp4 import exporter, omm
# After
from astroz.api import Satrec, SatrecArray, jday, WGS72
from astroz import exporter, ommThat covers Satrec.twoline2rv, Satrec() with sgp4init(...), sgp4/sgp4_array, SatrecArray, the element attributes, exporter.export_tle/export_omm, and omm.parse_csv/parse_xml/initialize. Gravity constants (WGS72OLD, WGS72, WGS84) and error codes (1, 2, 3, 4, 6) use python-sgp4's values, and a decayed satellite (error 6) still gets its position, as in python-sgp4. The test suite checks all of this against python-sgp4 itself.
Most of the speedup comes from the batch methods, so if your code loops over times or satellites, switch to sgp4_array or SatrecArray:
# Loop over times
for jd, fr in zip(jd_list, fr_list):
e, r, v = sat.sgp4(jd, fr)
# becomes
e, r, v = sat.sgp4_array(np.array(jd_list), np.array(fr_list))
# Loop over satellites
for sat in satellites:
e, r, v = sat.sgp4_array(jd, fr)
# becomes
e, r, v = SatrecArray(satellites).sgp4(jd, fr)Pass velocities=False to SatrecArray.sgp4 if you only need positions.
| Method | python-sgp4 | astroz | Speedup |
|---|---|---|---|
sat.sgp4() in a loop |
1.3M/s | 2.5M/s | 2x |
sat.sgp4_array() |
2.7M/s | 15M/s | 5x |
SatrecArray.sgp4() |
3M/s | 290M/s | 100x |
sgp4initimplements the improved operation mode,'i'(python-sgp4's default);'a'raisesValueError. It also accepts the catalog number as a TLE field string such as"A0404".SatrecArraymatches python-sgp4 to millimetres for near-earth orbits and to metres for deep-space ones, where the SIMD batch path's trigonometry adds a few parts per billion.Satrec.sgp4matches to the micrometre.- These
Satrecattributes are not implemented:- Elements after propagation:
Om,am,em,im,mm,nm,om - Element rates:
argpdot,mdot,nodedot - Gravity constants on the object:
j2,j3,j4,mu, etc. Earth constants are available asastroz.EARTH_MU,astroz.EARTH_J2andastroz.EARTH_R_EQ.
- Elements after propagation:
| Constellation (13,478 sats x 1,440 steps) | Throughput |
|---|---|
| 1 thread | 37.7M props/sec |
| 16 threads | 303M props/sec |
Measured on an AMD Ryzen 7 7840U with AVX512.
Set ASTROZ_THREADS to control thread count (defaults to all cores).
from astroz import (
hohmann_transfer, bi_elliptic_transfer, lambert,
orbital_velocity, orbital_period, escape_velocity,
EARTH_MU,
)
# Hohmann transfer: LEO (400 km) to GEO
result = hohmann_transfer(EARTH_MU, 6778, 42164)
print(f"Total ΔV: {result['total_dv']:.3f} km/s")
print(f"Transfer time: {result['transfer_time_days']:.2f} days")
# Bi-elliptic transfer (cheaper than Hohmann for large radius ratios)
result = bi_elliptic_transfer(EARTH_MU, 6778, 42164, 100000)
print(f"Total ΔV: {result['total_dv']:.3f} km/s")
# Lambert solver: find transfer orbit between two positions
r1 = (7000.0, 0.0, 0.0)
r2 = (0.0, 7000.0, 0.0)
tof = 3600.0 # 1 hour
result = lambert(EARTH_MU, r1, r2, tof)
print(f"Departure velocity: {result['departure_velocity']}")
# Orbital velocity, period, escape velocity
v = orbital_velocity(EARTH_MU, 6778) # Circular orbit at 400 km
T = orbital_period(EARTH_MU, 6778) # Period in seconds
v_esc = escape_velocity(EARTH_MU, 6778) # Escape velocityPropagate orbits with perturbation models (J2, atmospheric drag) using RK4 or
Dormand-Prince 8(7) adaptive integrators. r_eq is required when using J2 or
drag perturbations. Drag uses an exponential Earth atmosphere model (sea-level
density 1.225 kg/m^3, scale height 7.249 km, cutoff altitude 1500 km).
drag_area is in m^2 and drag_mass in kg.
from astroz import propagate_numerical, EARTH_MU, EARTH_J2, EARTH_R_EQ
# Initial state: LEO circular orbit [x, y, z, vx, vy, vz] (km, km/s)
state = (6778.0, 0.0, 0.0, 0.0, 7.668, 0.0)
# Two-body propagation (1 orbit, 60s steps)
times, states = propagate_numerical(state, 0.0, 5554.0, 60.0, EARTH_MU)
# With J2 perturbation
times, states = propagate_numerical(
state, 0.0, 5554.0, 60.0, EARTH_MU,
j2=EARTH_J2, r_eq=EARTH_R_EQ,
)
# With J2 + atmospheric drag
times, states = propagate_numerical(
state, 0.0, 86400.0, 60.0, EARTH_MU,
j2=EARTH_J2, r_eq=EARTH_R_EQ,
drag_cd=2.2, drag_area=10.0, drag_mass=500.0,
integrator="dp87", # or "rk4"
)from astroz import EARTH_MU, EARTH_R_EQ, EARTH_J2, SUN_MU, MOON_MU
print(f"Earth μ: {EARTH_MU} km³/s²")
print(f"Earth radius: {EARTH_R_EQ} km")
print(f"Earth J2: {EARTH_J2}")
print(f"Sun μ: {SUN_MU} km³/s²")
print(f"Moon μ: {MOON_MU} km³/s²")Requires Zig and Python 3.10+.
cd bindings/python
pip install -e .