Skip to content

Speed up propagation step time by 3x - #175

Merged
ssmichael1 merged 6 commits into
ssmichael1:mainfrom
scottshambaugh:perf
Sep 11, 2026
Merged

Speed up propagation step time by 3x#175
ssmichael1 merged 6 commits into
ssmichael1:mainfrom
scottshambaugh:perf

Conversation

@scottshambaugh

@scottshambaugh scottshambaugh commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

I've been trying out different numerical propagators, and this is the fastest so far, with results aligning with both Orekit and Tudat. Nice project!

This PR rolls in the following performance improvements:

  • Compile gravity twice, the second time with fma (fused multiply add) compiler support, and use fma at runtime if the machine supports it. Cuts the gravity evaluation by ~5x
  • Pull out some MSIS calculations that were being computed multiple times per step
  • The 5-iteration Bowring geodetic conversion converged in a single refinement step in my testing, so drop the loop
  • Reuse already fetched space weather
  • Faster version string import (skip importlib)
  • Skip fully parsing gravity term data higher than the requested order

One more performance issue to flag: the initial timestep PID convergence is slow, and is the dominant time on short-duration propagation - about half the evaluations for a one-hour horizon. A warm start or forced initial step would drastically reduce the needed calls there. I believe this would require a coordinated numeris change.

benchmark before after
earthgravity/accel 40x40 13.6 us 2.6 us
nrlmsise/density_400km 2.48 us 1.80 us
30 days prop (the script below) 4.8 s 1.5 s
import time
import numpy as np
import satkit as sk
settings = sk.propsettings()
settings.gravity_model, settings.gravity_degree, settings.gravity_order = sk.gravmodel.egm96, 40, 40
settings.use_sun_gravity = settings.use_moon_gravity = settings.use_relativistic_correction = True
settings.tide_model, settings.integrator = sk.tidemodel.solid_step1, sk.integrator.rkv98
settings.abs_error = settings.rel_error = 1e-9
settings.use_spaceweather = True
mass_kg, area_m2, cd, cr = 500.0, 5.0, 2.2, 1.2
props = sk.satproperties(cdaoverm=cd * area_m2 / mass_kg, craoverm=cr * area_m2 / mass_kg)
epoch = sk.time(2025, 1, 1, 12, 0, 0)
altitude_m, inclination_rad = 550e3, np.radians(51.6)
r = sk.consts.earth_radius + altitude_m
v = np.sqrt(sk.consts.mu_earth / r)
state = np.array([r, 0.0, 0.0, 0.0, v * np.cos(inclination_rad), v * np.sin(inclination_rad)])
sk.propagate(state, epoch, duration_secs=60.0, propsettings=settings, satproperties=props)
t = time.perf_counter()
for day in range(30):
    res = sk.propagate(state, epoch + sk.duration.from_seconds(day * 86400.0), duration_secs=86400.0, propsettings=settings, satproperties=props)
    state = np.asarray(res.state)
print(f"30 days in {time.perf_counter() - t:.2f} s, final state {state.tolist()}")

@scottshambaugh scottshambaugh changed the title Speed up propagation per-step time 3x, and fix space weather data timing Speed up propagation step time by 3x Sep 11, 2026
@scottshambaugh

scottshambaugh commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Space weather timing bug broken out to #176 instead of being lumped in with this.

@ssmichael1

Copy link
Copy Markdown
Owner

This is a very impressive speedup. I'll take a closer look over the weekend, and merge if performance/accuracy is expected. Thank you.

@ssmichael1

Copy link
Copy Markdown
Owner

For your comment about the initial timestep PID convergence: I'll have to think about this one. If I remember correctly, I had a tough time figuring this out and ended up copying the algorithm for initial step size from the ODE module in the Julia programming language. I could:

  1. Allow user-settable initial step; this is an easy win.
  2. Look at adjusting the PID coefficients (but could have big downstream effects; nervous about tuning)
  3. Modify initial step size calculation to be less conservative.

I'll look at this over the weekend as well, and thanks again for your contributions.

@ssmichael1
ssmichael1 merged commit 33725f2 into ssmichael1:main Sep 11, 2026
8 checks passed
@ssmichael1

Copy link
Copy Markdown
Owner

All looks good. Merged. Thank you!

ssmichael1 added a commit that referenced this pull request Sep 12, 2026
Claude-Session: https://claude.ai/code/session_01XX7RJzhJFFjTyNpRhgCsqF

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
ssmichael1 added a commit that referenced this pull request Sep 12, 2026
… (numeris 0.6)

The adaptive integrators no longer start from numeris' Hairer starting-step
heuristic, which is scale sensitive: for an orbit in metres and seconds at
1e-9 tolerance it started RKV98 at a fraction of a millisecond against a
~270 s working step and spent about half the force evaluations of a
one-hour arc growing into it (satkit #175 discussion).

- `PropSettings::initial_step_secs: Option<f64>` — first step to attempt.
  `None` (default) derives `1.5 · |r|/|v| · tol^(1/(p+1))` from the initial
  state, the tolerances (`tol = rel_error + abs_error/|r|`) and the
  integrator order `p`: the settled stride of an order-p method scales as
  tol^(1/(p+1)), and the constant is fit to LEO strides. Across RKTS54..RKV98
  and 1e-6..1e-12 the hint lands within 0.64–2.6× of the settled stride, so
  the controller is on stride in a step or two. Ignored by Gauss-Jackson 8.
  Zero / non-finite → error.
- `PropagationResult::next_step_secs` — the integrator's working stride at
  the end of the arc (numeris `Solution::next_step`; GJ8's fixed step;
  0 for a zero-duration arc), signed like the propagation direction, so a
  follow-on arc can warm-start by passing it back.
- Python: `propsettings.initial_step_secs` (kwarg + property, pickled) and
  `propresult.next_step_secs`; stubs and docstrings; integrators guide
  section; CHANGELOG.
- numeris requirement 0.5.18 → 0.6.0.

One-hour 550 km arc, RKV98 at 1e-9, 8x8 gravity: 525 evals with the
old-heuristic-sized start → 315 with the default hint → 294 warm-started
from the previous hour; every RK integrator at every tolerance from 1e-6
to 1e-12 is now within one step of the warm-start cost.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VHexyxYasfatXebfxtphw
ssmichael1 added a commit that referenced this pull request Sep 12, 2026
…ris 0.6) (#178)

* feat(orbitprop): initial_step_secs hint and next_step_secs warm start (numeris 0.6)

The adaptive integrators no longer start from numeris' Hairer starting-step
heuristic, which is scale sensitive: for an orbit in metres and seconds at
1e-9 tolerance it started RKV98 at a fraction of a millisecond against a
~270 s working step and spent about half the force evaluations of a
one-hour arc growing into it (satkit #175 discussion).

- `PropSettings::initial_step_secs: Option<f64>` — first step to attempt.
  `None` (default) derives `1.5 · |r|/|v| · tol^(1/(p+1))` from the initial
  state, the tolerances (`tol = rel_error + abs_error/|r|`) and the
  integrator order `p`: the settled stride of an order-p method scales as
  tol^(1/(p+1)), and the constant is fit to LEO strides. Across RKTS54..RKV98
  and 1e-6..1e-12 the hint lands within 0.64–2.6× of the settled stride, so
  the controller is on stride in a step or two. Ignored by Gauss-Jackson 8.
  Zero / non-finite → error.
- `PropagationResult::next_step_secs` — the integrator's working stride at
  the end of the arc (numeris `Solution::next_step`; GJ8's fixed step;
  0 for a zero-duration arc), signed like the propagation direction, so a
  follow-on arc can warm-start by passing it back.
- Python: `propsettings.initial_step_secs` (kwarg + property, pickled) and
  `propresult.next_step_secs`; stubs and docstrings; integrators guide
  section; CHANGELOG.
- numeris requirement 0.5.18 → 0.6.0.

One-hour 550 km arc, RKV98 at 1e-9, 8x8 gravity: 525 evals with the
old-heuristic-sized start → 315 with the default hint → 294 warm-started
from the previous hour; every RK integrator at every tolerance from 1e-6
to 1e-12 is now within one step of the warm-start cost.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VHexyxYasfatXebfxtphw

* orbitprop: run rkv98 as the 16-stage tableau when interpolation is off

The five extra stages of the 21-stage Verner 9(8) tableau exist only to
build the interpolant. With `enable_interp = false` nothing is stored to
interpolate, so `Integrator::RKV98` now dispatches to `RKV98NoInterp`:
same order and error control, 24% fewer force evaluations per step.
The result still reports `Integrator::RKV98`; `interp` fails with
"no dense output" exactly as before. Results for that combination change
at the tolerance level (a different tableau takes different steps).

Tests pin evals == 16 × steps without interpolation and 21 × steps with,
and agreement of the two final positions to 1 cm over an hour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VHexyxYasfatXebfxtphw

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants