diff --git a/Cargo.lock b/Cargo.lock index 51f7520..5e22fd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,6 +9,8 @@ dependencies = [ "apsis-coverage", "apsis-geodesy", "apsis-math", + "apsis-sgp4", + "apsis-tle", ] [[package]] diff --git a/crates/network/apsis-access/Cargo.toml b/crates/network/apsis-access/Cargo.toml index dcc0503..7094d09 100644 --- a/crates/network/apsis-access/Cargo.toml +++ b/crates/network/apsis-access/Cargo.toml @@ -13,4 +13,11 @@ workspace = true [dependencies] apsis-math.workspace = true apsis-geodesy.workspace = true +apsis-tle.workspace = true apsis-coverage.workspace = true + +[dev-dependencies] +apsis-sgp4.workspace = true +apsis-coverage.workspace = true +apsis-geodesy.workspace = true +apsis-tle.workspace = true diff --git a/crates/network/apsis-access/src/batch.rs b/crates/network/apsis-access/src/batch.rs new file mode 100644 index 0000000..bf04666 --- /dev/null +++ b/crates/network/apsis-access/src/batch.rs @@ -0,0 +1,38 @@ +//! Searching access windows for many tracks, optionally across several +//! threads. +//! +//! A ground station sees a whole constellation, so the access schedule is +//! the search of one track per satellite. Each track is independent of the +//! others, which makes the search the natural place to use several cores: the +//! work is proportional to the number of satellites, and a shell of a few +//! hundred gives enough of it to be worth the threads. +//! +//! The parallel entry point returns exactly what the sequential one does, in +//! the same order, so a caller can switch between them without changing what +//! it computes. + +use crate::{AccessError, AccessFinder, OrbitSample, PassSummary}; +use apsis_math::parallel::map_index; + +/// Searches access for many tracks, in order. +/// +/// The sequential form, provided for comparison and for small sets where +/// starting a thread would cost more than the search. +pub fn find_all( + finder: &AccessFinder, + tracks: &[Vec], +) -> Vec> { + tracks.iter().map(|track| finder.find(track)).collect() +} + +/// Searches access for many tracks, across several threads. +/// +/// The result matches [`find_all`] element for element and in the same order. +/// A track that cannot be searched yields its error in the same position the +/// sequential form would put it, so one bad track does not hide the rest. +pub fn find_all_parallel( + finder: &AccessFinder, + tracks: &[Vec], +) -> Vec> { + map_index(tracks.len(), |index| finder.find(&tracks[index])) +} diff --git a/crates/network/apsis-access/src/lib.rs b/crates/network/apsis-access/src/lib.rs index 69b6ed6..4be4440 100644 --- a/crates/network/apsis-access/src/lib.rs +++ b/crates/network/apsis-access/src/lib.rs @@ -34,10 +34,12 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod batch; mod error; mod finder; mod pass; +pub use batch::{find_all, find_all_parallel}; pub use error::{AccessError, AccessErrorKind}; pub use finder::{AccessFinder, OrbitSample}; pub use pass::{Pass, PassSummary}; diff --git a/crates/network/apsis-access/tests/batch.rs b/crates/network/apsis-access/tests/batch.rs new file mode 100644 index 0000000..0cb1f5f --- /dev/null +++ b/crates/network/apsis-access/tests/batch.rs @@ -0,0 +1,115 @@ +//! Tests for the batch access search. +//! +//! The parallel form must produce the same pass summaries, in the same +//! order, as the sequential one. Every test compares the two rather than +//! against a constant, so an ordering or content difference is caught. + +use apsis_access::{AccessFinder, OrbitSample, find_all, find_all_parallel}; +use apsis_coverage::GroundPoint; +use apsis_geodesy::Geodetic; +use apsis_math::Vec3; +use apsis_sgp4::{GravityModel, Satellite}; +use apsis_tle::TwoLineElement; + +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", +); + +/// Returns a finder for a station on the equator. +fn finder() -> AccessFinder { + AccessFinder { + station: GroundPoint::new(Geodetic::new(0.0, 0.0, 0.0), 10.0_f64.to_radians()), + sample_step_seconds: 60.0, + crossing_tolerance_seconds: 0.01, + } +} + +/// Returns a track of Earth-fixed samples for one satellite. +/// +/// The rotation is folded into a simple sweep so the track is a plausible +/// ground track; the exact geometry is not what this test is about, only +/// that the batch search reproduces the sequential one. +fn track(satellite: &Satellite, samples: usize) -> Vec { + (0..samples) + .map(|index| { + let seconds = index as f64 * 60.0; + let state = satellite + .propagate_minutes(seconds / 60.0) + .expect("the track propagates"); + // A slow sweep in longitude with a constant latitude stands in + // for the rotation into the Earth-fixed frame. + let angle = seconds / 4_000.0; + let radius = 6_900.0; + let position = Vec3::new(radius * angle.cos(), radius * angle.sin(), radius * 0.3); + OrbitSample::new(seconds, position) + }) + .collect() +} + +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_single_track_matches_the_sequential_form() { + let finder = finder(); + let tracks = vec![track(&satellite(), 120)]; + assert_eq!( + find_all_parallel(&finder, &tracks), + find_all(&finder, &tracks) + ); +} + +#[test] +fn a_shell_of_tracks_matches_the_sequential_form() { + // Long enough that the parallel path takes the multi thread branch. + let finder = finder(); + let satellite = satellite(); + let tracks: Vec> = (0..200).map(|_| track(&satellite, 120)).collect(); + assert_eq!( + find_all_parallel(&finder, &tracks), + find_all(&finder, &tracks) + ); +} + +#[test] +fn an_empty_set_of_tracks_is_empty() { + let finder = finder(); + assert!(find_all_parallel(&finder, &[]).is_empty()); +} + +#[test] +fn a_bad_track_does_not_hide_the_others() { + // A track that is too short or out of order yields an error in both + // forms, at the same position, and the good tracks are still searched. + let finder = finder(); + let satellite = satellite(); + let mut tracks = vec![track(&satellite, 120), vec![], track(&satellite, 120)]; + // The empty track is a degenerate case; search it as given. + let sequential = find_all(&finder, &tracks); + let parallel = find_all_parallel(&finder, &tracks); + assert_eq!(parallel, sequential); + tracks.clear(); + assert!(tracks.is_empty()); +} + +#[test] +fn the_result_is_in_input_order() { + // Three tracks with different lengths, so each summary differs and the + // ordering is observable. + let finder = finder(); + let satellite = satellite(); + let tracks = vec![ + track(&satellite, 40), + track(&satellite, 120), + track(&satellite, 80), + ]; + let parallel = find_all_parallel(&finder, &tracks); + assert_eq!(parallel.len(), 3); + for (index, summary) in parallel.iter().enumerate() { + let expected = finder.find(&tracks[index]).expect("the track is valid"); + assert_eq!(summary.as_ref().expect("the track is valid"), &expected); + } +}