From d7037046803ded576bb512711953faa9e2859eb2 Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Wed, 30 Sep 2026 18:36:14 +0800 Subject: [PATCH] sgp4: propagate a track across several threads A coverage run wants a satellite's state at every minute of a day, and each state is independent, so the loop is a candidate for several cores and the work is large enough to pay for the threads. The parallel form returns the same values in the same order as the sequential one, including reporting a decayed instant as None rather than stopping early. --- crates/orbit/apsis-sgp4/src/batch.rs | 63 ++++++++++++++++ crates/orbit/apsis-sgp4/src/lib.rs | 4 ++ crates/orbit/apsis-sgp4/tests/batch.rs | 99 ++++++++++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 crates/orbit/apsis-sgp4/src/batch.rs create mode 100644 crates/orbit/apsis-sgp4/tests/batch.rs diff --git a/crates/orbit/apsis-sgp4/src/batch.rs b/crates/orbit/apsis-sgp4/src/batch.rs new file mode 100644 index 0000000..23ecd1f --- /dev/null +++ b/crates/orbit/apsis-sgp4/src/batch.rs @@ -0,0 +1,63 @@ +//! Batch propagation over many instants, optionally across several threads. +//! +//! A single satellite's track is the common case for a coverage run or an +//! access search: the caller wants the state at every minute of a day, for +//! every satellite in a shell. Each state is independent of the others, so +//! the loop is a candidate for several cores, and the work is large enough +//! that starting a thread pays for itself. +//! +//! The parallel entry point produces the same values as the sequential one, +//! in the same order. It differs only in how the work is distributed. + +use crate::{Satellite, State}; +use apsis_math::parallel::map_index; + +/// Propagates a satellite at a list of minutes from its epoch, in order. +/// +/// The sequential form, provided for comparison and for small lists where +/// starting a thread would cost more than the work. +pub fn propagate_minutes_at(satellite: &Satellite, minutes: &[f64]) -> Vec> { + minutes + .iter() + .map(|&value| satellite.propagate_minutes(value).ok()) + .collect() +} + +/// Propagates a satellite at a list of minutes from its epoch, across several +/// threads. +/// +/// The result matches [`propagate_minutes_at`] element for element and in the +/// same order. A minute at which the model reports a decay yields `None`, as +/// it does in the sequential form; the threads do not stop at the first such +/// minute, because a caller usually wants the whole track and will decide for +/// itself what a decay means. +/// +/// Propagation is deterministic and depends only on the minutes, so the +/// result does not depend on how the work was split. +pub fn propagate_minutes_at_parallel(satellite: &Satellite, minutes: &[f64]) -> Vec> { + map_index(minutes.len(), |index| { + satellite.propagate_minutes(minutes[index]).ok() + }) +} + +/// Propagates several satellites at one instant, in order. +/// +/// The per satellite work is independent, so a shell's state at a single +/// instant is a natural batch. The satellites are read only, so they are +/// shared across the threads by reference. +pub fn propagate_at(satellites: &[Satellite], tai_mjd: f64) -> Vec> { + satellites + .iter() + .map(|satellite| satellite.propagate_at(tai_mjd).ok()) + .collect() +} + +/// Propagates several satellites at one instant, across several threads. +/// +/// The result matches [`propagate_at`] element for element and in the same +/// order. +pub fn propagate_at_parallel(satellites: &[Satellite], tai_mjd: f64) -> Vec> { + map_index(satellites.len(), |index| { + satellites[index].propagate_at(tai_mjd).ok() + }) +} diff --git a/crates/orbit/apsis-sgp4/src/lib.rs b/crates/orbit/apsis-sgp4/src/lib.rs index a3bfcb8..3387aee 100644 --- a/crates/orbit/apsis-sgp4/src/lib.rs +++ b/crates/orbit/apsis-sgp4/src/lib.rs @@ -53,6 +53,7 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod batch; mod deep_space; mod error; mod init; @@ -61,6 +62,9 @@ mod propagate; mod satellite; mod state; +pub use batch::{ + propagate_at, propagate_at_parallel, propagate_minutes_at, propagate_minutes_at_parallel, +}; pub use error::{PropagationError, PropagationErrorKind}; pub use init::Elements; pub use model::GravityModel; diff --git a/crates/orbit/apsis-sgp4/tests/batch.rs b/crates/orbit/apsis-sgp4/tests/batch.rs new file mode 100644 index 0000000..9a79e89 --- /dev/null +++ b/crates/orbit/apsis-sgp4/tests/batch.rs @@ -0,0 +1,99 @@ +//! Tests for the batch propagation entry points. +//! +//! The property that matters is that the parallel forms produce exactly the +//! values the sequential forms do, in the same order. Every test compares +//! against the sequential answer rather than against a constant, so a +//! mismatch in either the propagation or the ordering is caught. + +use apsis_sgp4::{ + GravityModel, Satellite, TwoLineElement, propagate_at, propagate_at_parallel, + propagate_minutes_at, propagate_minutes_at_parallel, +}; + +const SAMPLE: &str = concat!( + "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927\r\n", + "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537\r\n", +); + +fn satellite() -> Satellite { + let element = TwoLineElement::parse(SAMPLE).expect("the sample parses"); + Satellite::from_element(&element, GravityModel::WGS72).expect("the orbit propagates") +} + +#[test] +fn a_short_track_matches_the_sequential_form() { + let satellite = satellite(); + let minutes: Vec = (0..100).map(|index| index as f64 * 1.0).collect(); + let sequential = propagate_minutes_at(&satellite, &minutes); + let parallel = propagate_minutes_at_parallel(&satellite, &minutes); + assert_eq!(parallel, sequential); +} + +#[test] +fn a_long_track_matches_the_sequential_form() { + // A day at a one minute step, which is long enough that the parallel path + // takes the multi thread branch. + let satellite = satellite(); + let minutes: Vec = (0..1_441).map(|index| index as f64).collect(); + let sequential = propagate_minutes_at(&satellite, &minutes); + let parallel = propagate_minutes_at_parallel(&satellite, &minutes); + assert_eq!(parallel, sequential); + assert_eq!(parallel.len(), 1_441); +} + +#[test] +fn a_backwards_track_matches() { + // Negative minutes propagate backwards and must behave the same way in + // both forms. + let satellite = satellite(); + let minutes: Vec = (0..2_000).map(|index| -(index as f64) * 0.5).collect(); + assert_eq!( + propagate_minutes_at_parallel(&satellite, &minutes), + propagate_minutes_at(&satellite, &minutes) + ); +} + +#[test] +fn an_empty_track_is_empty() { + let satellite = satellite(); + assert!(propagate_minutes_at_parallel(&satellite, &[]).is_empty()); +} + +#[test] +fn a_decayed_instant_is_reported_in_both_forms() { + // Far enough ahead that the drag term brings the satellite down, both + // forms must agree that the instant is unusable rather than one of them + // stopping early. + let satellite = satellite(); + let minutes = vec![0.0, 1.0, 60.0 * 24.0 * 365.0 * 20.0]; + let sequential = propagate_minutes_at(&satellite, &minutes); + let parallel = propagate_minutes_at_parallel(&satellite, &minutes); + assert_eq!(parallel, sequential); +} + +#[test] +fn a_shell_at_one_instant_matches() { + let satellite = satellite(); + let shell = vec![satellite; 500]; + let instant = 54_723.5; + assert_eq!( + propagate_at_parallel(&shell, instant), + propagate_at(&shell, instant) + ); +} + +#[test] +fn a_shell_result_is_in_input_order() { + // The states of a repeated satellite are identical, so ordering is checked + // through the minutes form, where each value differs. + let satellite = satellite(); + let minutes: Vec = (0..3_000).map(|index| index as f64 * 0.25).collect(); + let parallel = propagate_minutes_at_parallel(&satellite, &minutes); + for (index, state) in parallel.iter().enumerate() { + let state = state.as_ref().expect("the orbit has not decayed here"); + let direct = satellite + .propagate_minutes(minutes[index]) + .expect("the orbit propagates"); + assert_eq!(state.position, direct.position, "at index {index}"); + } +}