Compare commits
No commits in common. "2acc2170ad26264e89b354b70c569aa7798fea2e" and "71b006bcb3588051092fa6657a4223c475365c19" have entirely different histories.
2acc2170ad
...
71b006bcb3
|
|
@ -34,14 +34,11 @@ jobs:
|
|||
cargo run -p apsis-rs --features network --example integrate_network
|
||||
cargo run -p apsis-rs --features ops --example integrate_ops
|
||||
cargo run -p apsis-rs --features sim --example integrate_sim
|
||||
- name: Build the benchmarks
|
||||
# The benchmarks are not run in CI, because their timings are only
|
||||
# meaningful on a quiet machine, but they must still compile so that
|
||||
# they do not rot.
|
||||
run: |
|
||||
cargo build -p apsis-rs --features full --example bench
|
||||
cargo build -p apsis-rs --features full --example bench_scale
|
||||
cargo build -p apsis-rs --features full --example bench_parallel
|
||||
- name: Build the benchmark
|
||||
# The benchmark is not run in CI, because its timings are only
|
||||
# meaningful on a quiet machine, but it must still compile so that it
|
||||
# does not rot.
|
||||
run: cargo build -p apsis-rs --features full --example bench
|
||||
- name: Documentation
|
||||
# Catches a doc link that no longer resolves, which is how a doc
|
||||
# comment drifts away from the code it describes.
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ dependencies = [
|
|||
"apsis-coverage",
|
||||
"apsis-geodesy",
|
||||
"apsis-math",
|
||||
"apsis-sgp4",
|
||||
"apsis-tle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -65,11 +65,6 @@ apsis-rs = { path = "...", features = ["orbit"] }
|
|||
- toolchain:`stable`,`edition 2024`,`rust-version 1.85`
|
||||
- 默认禁止任何第三方 crate,包括开发依赖与 `build.rs`
|
||||
- 库代码不使用 `unwrap()` / `expect()` / `panic!` / `unsafe`
|
||||
- 外部输入的失败一律走 `Result` / `Option`,不用 `unwrap` / `expect` 转换
|
||||
- 允许对**已守卫的内部不变量**用 `expect("…")` 断言不可能的分支(如刚 `push`
|
||||
后的 `last()`、前置 `is_none()` 已检查后的取值)。这些断言不处理不可信
|
||||
输入,而是把"若此处为空则是编码错误"写成可读的失败信息;全库共 12 处,
|
||||
逐处均有前置守卫。
|
||||
- 公共 API 必须有文档注释,注释自包含
|
||||
|
||||
## 目录
|
||||
|
|
@ -97,12 +92,6 @@ cargo run -p apsis-rs --features sim --example integrate_sim
|
|||
|
||||
# 热点路径基准(release 下运行)
|
||||
cargo run --release -p apsis-rs --features full --example bench
|
||||
|
||||
# 规模扫描:输入规模递增,观察每单位工作量的增长趋势
|
||||
cargo run --release -p apsis-rs --features full --example bench_scale
|
||||
|
||||
# 并行扫描:同一批工作量在 1/2/4/8/16 线程下的加速比与效率
|
||||
cargo run --release -p apsis-rs --features full --example bench_parallel
|
||||
```
|
||||
|
||||
## 许可
|
||||
|
|
|
|||
|
|
@ -209,19 +209,3 @@ required-features = ["full"]
|
|||
[[example]]
|
||||
name = "dump_coverage"
|
||||
required-features = ["network"]
|
||||
|
||||
[[example]]
|
||||
name = "dump_kepler"
|
||||
required-features = ["orbit"]
|
||||
|
||||
[[example]]
|
||||
name = "dump_tle"
|
||||
required-features = ["orbit"]
|
||||
|
||||
[[example]]
|
||||
name = "bench_scale"
|
||||
required-features = ["full"]
|
||||
|
||||
[[example]]
|
||||
name = "bench_parallel"
|
||||
required-features = ["full"]
|
||||
|
|
|
|||
|
|
@ -1,421 +0,0 @@
|
|||
//! Parallel scale benchmark: how well the hot paths use the cores.
|
||||
//!
|
||||
//! The serial `bench_scale` example measures the cost of a path. This one
|
||||
//! asks the next question: when the work is spread over the cores, how much
|
||||
//! of the wall clock does that save, and where does the parallel version
|
||||
//! stop being worth it.
|
||||
//!
|
||||
//! The paths chosen are the ones a real run parallelises, because each is
|
||||
//! either trivially parallel or has a clear serial tail:
|
||||
//!
|
||||
//! - **constellation propagation** propagates every satellite independently,
|
||||
//! so it is embarrassingly parallel and should scale nearly linearly.
|
||||
//! - **access search** searches each track independently, likewise.
|
||||
//! - **topology construction** decides each pair of satellites independently,
|
||||
//! but folding the results into one graph is serial, so it is the case
|
||||
//! that shows the cost of a serial tail.
|
||||
//!
|
||||
//! The timing compares the same total work done serially and across a range
|
||||
//! of thread counts, and reports the speedup against both the serial time
|
||||
//! and the ideal linear time. The two differ, and the difference is the
|
||||
//! parallel overhead: thread spawn, work imbalance, and the serial tail.
|
||||
//!
|
||||
//! There is no warm-up, for the reason given in `bench_scale`: the first
|
||||
//! call pays for whatever the path allocates on first use, and a caller
|
||||
//! waits for that too. The serial time and the parallel time are both
|
||||
//! measured cold, so the comparison is between two numbers the caller could
|
||||
//! actually observe.
|
||||
//!
|
||||
//! Usage:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --release -p apsis-rs --features full --example bench_parallel
|
||||
//! ```
|
||||
|
||||
use apsis_access::{AccessFinder, OrbitSample};
|
||||
use apsis_coord::Eci;
|
||||
use apsis_coverage::GroundPoint;
|
||||
use apsis_frames::EarthRotationAngle;
|
||||
use apsis_geodesy::Geodetic;
|
||||
use apsis_isl::mutual_visibility;
|
||||
use apsis_math::Vec3;
|
||||
use apsis_sgp4::{GravityModel, Satellite};
|
||||
use apsis_tle::TwoLineElement;
|
||||
use apsis_topology::Topology;
|
||||
use std::hint::black_box;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The element set used as the source of every propagated track.
|
||||
const ISS: &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",
|
||||
);
|
||||
|
||||
/// The body radius, in kilometers, taken from the shared constants.
|
||||
const BODY_RADIUS_KM: f64 = apsis_core::constant::EARTH_EQUATORIAL_RADIUS;
|
||||
|
||||
/// The sample step, in seconds.
|
||||
const TRACK_STEP_SECONDS: f64 = 60.0;
|
||||
|
||||
/// Runs a path once and returns how long it took.
|
||||
fn measure<F, T>(path: F) -> Duration
|
||||
where
|
||||
F: FnOnce() -> T,
|
||||
{
|
||||
let start = Instant::now();
|
||||
black_box(path());
|
||||
start.elapsed()
|
||||
}
|
||||
|
||||
/// Runs a path `repeats` times and returns the fastest duration.
|
||||
///
|
||||
/// The fastest of several runs is the least contaminated by whatever else
|
||||
/// the machine is doing, and it is the figure that scales cleanly, because a
|
||||
/// scheduling hiccup on one run cannot inflate the reference every later
|
||||
/// number is divided by. The cold cost of the very first call is still
|
||||
/// reported on its own, so nothing is hidden.
|
||||
fn measure_best<F, T>(repeats: u32, path: F) -> Duration
|
||||
where
|
||||
F: Fn() -> T,
|
||||
{
|
||||
let mut best = Duration::MAX;
|
||||
for _ in 0..repeats {
|
||||
let elapsed = measure(&path);
|
||||
if elapsed < best {
|
||||
best = elapsed;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Prints one row.
|
||||
///
|
||||
/// The efficiency is the speedup divided by the thread count, so one means
|
||||
/// the path used every core fully. A value well below one means the threads
|
||||
/// were idle, which is what a serial tail looks like.
|
||||
fn row(threads: usize, wall: Duration, work: Duration, cores: usize) {
|
||||
let wall_ms = wall.as_secs_f64() * 1e3;
|
||||
let work_ms = work.as_secs_f64() * 1e3;
|
||||
let speedup = work_ms / wall_ms;
|
||||
let efficiency = speedup / threads as f64;
|
||||
println!(
|
||||
"{threads:>6} {wall_ms:>11.3} {work_ms:>11.3} {speedup:>9.2} {efficiency:>9.2} {cores:>8}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a prepared satellite.
|
||||
fn satellite() -> Satellite {
|
||||
let element = TwoLineElement::parse(ISS).expect("the sample parses");
|
||||
Satellite::from_element(&element, GravityModel::WGS72).expect("the orbit propagates")
|
||||
}
|
||||
|
||||
/// The epoch of the sample element set, as a Modified Julian Date.
|
||||
fn epoch_mjd() -> f64 {
|
||||
let element = TwoLineElement::parse(ISS).expect("the sample parses");
|
||||
element
|
||||
.epoch
|
||||
.mjd(apsis_time::TimeScale::Utc)
|
||||
.expect("the epoch is inside the leap second table")
|
||||
}
|
||||
|
||||
/// Returns a track of Earth-fixed samples for one satellite.
|
||||
fn track(satellite: &Satellite, samples: usize, epoch_mjd: f64) -> Vec<OrbitSample> {
|
||||
(0..samples)
|
||||
.map(|index| {
|
||||
let seconds = index as f64 * TRACK_STEP_SECONDS;
|
||||
let state = satellite
|
||||
.propagate_minutes(seconds / 60.0)
|
||||
.expect("the track propagates");
|
||||
let angle =
|
||||
EarthRotationAngle::from_ut1_days(epoch_mjd - 51_544.5 + seconds / 86_400.0);
|
||||
let fixed = Eci::new(state.position).to_ecef(angle).vector();
|
||||
OrbitSample::new(seconds, fixed)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Propagates a whole constellation over a day, serially.
|
||||
///
|
||||
/// The serial time is the reference every parallel number is divided by.
|
||||
fn propagate_serial(satellite: &Satellite, satellites: usize, samples: usize) -> usize {
|
||||
let mut count = 0_usize;
|
||||
for _ in 0..satellites {
|
||||
for index in 0..samples {
|
||||
let seconds = index as f64 * TRACK_STEP_SECONDS;
|
||||
let state = satellite
|
||||
.propagate_minutes(seconds / 60.0)
|
||||
.expect("the track propagates");
|
||||
black_box(state.position);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Propagates a whole constellation over a day across `threads` threads.
|
||||
///
|
||||
/// Each thread takes a disjoint slice of the satellites, so the work splits
|
||||
/// evenly and no two threads write the same state. The `scope` form of
|
||||
/// spawning borrows the satellite rather than requiring it to outlive the
|
||||
/// threads, which is what lets the reference be shared without cloning.
|
||||
fn propagate_parallel(
|
||||
satellite: &Satellite,
|
||||
satellites: usize,
|
||||
samples: usize,
|
||||
threads: usize,
|
||||
) -> usize {
|
||||
let chunk = satellites.div_ceil(threads);
|
||||
let total = std::sync::atomic::AtomicUsize::new(0);
|
||||
std::thread::scope(|scope| {
|
||||
for worker in 0..threads {
|
||||
let first = worker * chunk;
|
||||
if first >= satellites {
|
||||
break;
|
||||
}
|
||||
let last = (first + chunk).min(satellites);
|
||||
let total = &total;
|
||||
scope.spawn(move || {
|
||||
let mut count = 0_usize;
|
||||
for _ in first..last {
|
||||
for index in 0..samples {
|
||||
let seconds = index as f64 * TRACK_STEP_SECONDS;
|
||||
let state = satellite
|
||||
.propagate_minutes(seconds / 60.0)
|
||||
.expect("the track propagates");
|
||||
black_box(state.position);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
total.fetch_add(count, std::sync::atomic::Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
});
|
||||
total.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Searches access for every track, serially.
|
||||
fn access_serial(tracks: &[Vec<OrbitSample>], finder: &AccessFinder) -> usize {
|
||||
let mut passes = 0_usize;
|
||||
for track in tracks {
|
||||
passes += finder.find(track).expect("the track is valid").count();
|
||||
}
|
||||
passes
|
||||
}
|
||||
|
||||
/// Searches access for every track across `threads` threads.
|
||||
///
|
||||
/// The tracks are read only and the finder is a copy, so each thread can
|
||||
/// take a disjoint slice without synchronisation.
|
||||
fn access_parallel(tracks: &[Vec<OrbitSample>], finder: &AccessFinder, threads: usize) -> usize {
|
||||
let chunk = tracks.len().div_ceil(threads);
|
||||
let total = std::sync::atomic::AtomicUsize::new(0);
|
||||
std::thread::scope(|scope| {
|
||||
for worker in 0..threads {
|
||||
let first = worker * chunk;
|
||||
if first >= tracks.len() {
|
||||
break;
|
||||
}
|
||||
let last = (first + chunk).min(tracks.len());
|
||||
let total = &total;
|
||||
let finder = *finder;
|
||||
scope.spawn(move || {
|
||||
let mut passes = 0_usize;
|
||||
for track in &tracks[first..last] {
|
||||
passes += finder.find(track).expect("the track is valid").count();
|
||||
}
|
||||
total.fetch_add(passes, std::sync::atomic::Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
});
|
||||
total.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Builds a topology serially, deciding each pair and folding it in.
|
||||
fn topology_serial(positions: &[Vec3], satellites: usize, range: f64) -> usize {
|
||||
let mut topology = Topology::new(satellites);
|
||||
for first in 0..satellites {
|
||||
for second in 0..satellites {
|
||||
if first != second
|
||||
&& mutual_visibility(positions[first], positions[second], range)
|
||||
.is_some_and(|visible| visible)
|
||||
{
|
||||
let _ = topology.add_edge(first, second, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
topology.edge_count()
|
||||
}
|
||||
|
||||
/// Builds a topology across `threads` threads.
|
||||
///
|
||||
/// The geometry of each pair is independent, so each thread computes the
|
||||
/// visible pairs in its own slice and returns them. Folding them into the
|
||||
/// single graph is serial, which is the point of measuring this path: it
|
||||
/// shows how much of the time the parallel part actually saves.
|
||||
fn topology_parallel(positions: &[Vec3], satellites: usize, range: f64, threads: usize) -> usize {
|
||||
let chunk = satellites.div_ceil(threads);
|
||||
let results: Vec<Vec<(usize, usize)>> = std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = (0..threads)
|
||||
.map(|worker| {
|
||||
let first = worker * chunk;
|
||||
let last = (first + chunk).min(satellites);
|
||||
scope.spawn(move || {
|
||||
let mut pairs = Vec::new();
|
||||
for first in first..last {
|
||||
for second in 0..satellites {
|
||||
if first != second
|
||||
&& mutual_visibility(positions[first], positions[second], range)
|
||||
.is_some_and(|visible| visible)
|
||||
{
|
||||
pairs.push((first, second));
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|handle| handle.join().expect("the worker did not panic"))
|
||||
.collect()
|
||||
});
|
||||
|
||||
// The serial tail: fold every visible pair into one graph.
|
||||
let mut topology = Topology::new(satellites);
|
||||
for pairs in &results {
|
||||
for &(first, second) in pairs {
|
||||
let _ = topology.add_edge(first, second, 1.0);
|
||||
}
|
||||
}
|
||||
topology.edge_count()
|
||||
}
|
||||
|
||||
/// Splits topology construction into its geometric and its folding phase.
|
||||
///
|
||||
/// Returns the time of the visibility test and the time of folding the
|
||||
/// visible pairs into a graph, both in milliseconds. The first is
|
||||
/// embarrassingly parallel, the second is a serial pass whose cost grows
|
||||
/// with the square of the edge count because each insertion checks for a
|
||||
/// duplicate by scanning the edges already stored.
|
||||
fn topology_phase_split(positions: &[Vec3], satellites: usize, range: f64) -> (f64, f64) {
|
||||
let start = Instant::now();
|
||||
let mut pairs = Vec::new();
|
||||
for first in 0..satellites {
|
||||
for second in 0..satellites {
|
||||
if first != second
|
||||
&& mutual_visibility(positions[first], positions[second], range)
|
||||
.is_some_and(|visible| visible)
|
||||
{
|
||||
pairs.push((first, second));
|
||||
}
|
||||
}
|
||||
}
|
||||
let visible_ms = start.elapsed().as_secs_f64() * 1e3;
|
||||
|
||||
let start = Instant::now();
|
||||
let mut topology = Topology::new(satellites);
|
||||
for &(first, second) in &pairs {
|
||||
let _ = topology.add_edge(first, second, 1.0);
|
||||
}
|
||||
let fold_ms = start.elapsed().as_secs_f64() * 1e3;
|
||||
|
||||
(visible_ms, fold_ms)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(|count| count.get())
|
||||
.unwrap_or(1);
|
||||
println!("ApsisRS parallel scale benchmark: release build, no external dependency");
|
||||
println!("available parallelism: {cores} core(s)");
|
||||
println!("no warm-up: the cold cost of the first call is reported, not discarded");
|
||||
|
||||
let satellite = satellite();
|
||||
let epoch = epoch_mjd();
|
||||
|
||||
// The thread counts swept: one is the serial reference, then the powers
|
||||
// of two up to the machine, then the machine's own count.
|
||||
let mut thread_counts = vec![1_usize, 2, 4, 8];
|
||||
if cores > 8 {
|
||||
thread_counts.push(cores);
|
||||
}
|
||||
thread_counts.retain(|count| *count <= cores.max(2));
|
||||
thread_counts.dedup();
|
||||
|
||||
// ---- One: constellation propagation over a day. ----------------------
|
||||
{
|
||||
let samples = 1_441_usize; // one day at a one minute step
|
||||
for &satellites in &[550_usize, 1_000, 2_000] {
|
||||
println!(
|
||||
"\none: propagate {satellites} satellite(s) over 1 day at 60 s ({samples} samples each)"
|
||||
);
|
||||
let serial = measure_best(3, || propagate_serial(&satellite, satellites, samples));
|
||||
let work = serial;
|
||||
for &threads in &thread_counts {
|
||||
let wall = measure_best(3, || {
|
||||
propagate_parallel(&satellite, satellites, samples, threads)
|
||||
});
|
||||
row(threads, wall, work, cores);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Two: access search. ---------------------------------------------
|
||||
{
|
||||
for &satellites in &[64_usize, 256] {
|
||||
let tracks: Vec<Vec<OrbitSample>> = (0..satellites)
|
||||
.map(|_| track(&satellite, 120, epoch))
|
||||
.collect();
|
||||
let finder = AccessFinder {
|
||||
station: GroundPoint::new(Geodetic::new(0.0, 0.0, 0.0), 10.0_f64.to_radians()),
|
||||
sample_step_seconds: TRACK_STEP_SECONDS,
|
||||
crossing_tolerance_seconds: 0.01,
|
||||
};
|
||||
println!("\ntwo: access search over {satellites} track(s), 2 h each");
|
||||
let serial = measure_best(5, || access_serial(&tracks, &finder));
|
||||
let work = serial;
|
||||
for &threads in &thread_counts {
|
||||
let wall = measure_best(5, || access_parallel(&tracks, &finder, threads));
|
||||
row(threads, wall, work, cores);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Three: topology construction. ------------------------------------
|
||||
{
|
||||
for &satellites in &[120_usize, 300] {
|
||||
let positions: Vec<Vec3> = (0..satellites)
|
||||
.map(|index| {
|
||||
let angle = index as f64 * 0.4;
|
||||
Vec3::new(
|
||||
(BODY_RADIUS_KM + 550.0) * angle.cos(),
|
||||
(BODY_RADIUS_KM + 550.0) * angle.sin(),
|
||||
index as f64 * 20.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
println!("\nthree: build topology over {satellites} satellite(s), 4500 km range");
|
||||
let serial = measure_best(2, || topology_serial(&positions, satellites, 4_500.0));
|
||||
// The split shows where the time actually goes: the geometric
|
||||
// visibility test is parallel, but folding the visible pairs into
|
||||
// the graph is a serial pass whose cost is superlinear in the
|
||||
// edge count. Reporting the two separately is the difference
|
||||
// between "parallelism did not help" and knowing why.
|
||||
let (vis_ms, fold_ms) = topology_phase_split(&positions, satellites, 4_500.0);
|
||||
println!(
|
||||
" split: visibility {vis_ms:.3} ms (parallel), edge fold {fold_ms:.3} ms (serial tail)"
|
||||
);
|
||||
for &threads in &thread_counts {
|
||||
let wall = measure_best(2, || {
|
||||
topology_parallel(&positions, satellites, 4_500.0, threads)
|
||||
});
|
||||
row(threads, wall, serial, cores);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("speedup is serial time over wall time; efficiency is speedup over thread count");
|
||||
println!("an efficiency near one means the cores were kept busy; a low value is a serial tail");
|
||||
}
|
||||
|
|
@ -1,323 +0,0 @@
|
|||
//! Scale benchmark for the paths a real workload hammers.
|
||||
//!
|
||||
//! The companion `bench` example measures the cost of a single call, which
|
||||
//! answers whether an operation is fast. This one measures how that cost
|
||||
//! grows with the size of the problem, which is the question that decides
|
||||
//! whether a constellation sized run is feasible. It sweeps the input size
|
||||
//! over powers of two and reports the time per unit of work, so a change in
|
||||
//! the growth rate shows up even when the absolute numbers move with the
|
||||
//! machine.
|
||||
//!
|
||||
//! The paths are the ones a planning cycle actually runs: propagating a
|
||||
//! Walker constellation, searching access windows for one station over every
|
||||
//! satellite, building the inter-satellite topology and routing through it,
|
||||
//! and planning a task set with precedence.
|
||||
//!
|
||||
//! Everything is measured in release mode. There is deliberately no warm-up:
|
||||
//! the first call pays for whatever the path allocates or caches on first
|
||||
//! use, and a caller waits for that too, so the cold cost is reported on its
|
||||
//! own column beside the steady state rather than being hidden behind a
|
||||
//! discarded run.
|
||||
//!
|
||||
//! Usage:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --release -p apsis-rs --features full --example bench_scale
|
||||
//! ```
|
||||
|
||||
use apsis_access::{AccessFinder, OrbitSample};
|
||||
use apsis_constellation::{Constellation, WalkerPattern};
|
||||
use apsis_coord::Eci;
|
||||
use apsis_coverage::GroundPoint;
|
||||
use apsis_frames::EarthRotationAngle;
|
||||
use apsis_geodesy::Geodetic;
|
||||
use apsis_isl::mutual_visibility;
|
||||
use apsis_math::Vec3;
|
||||
use apsis_resource::{Resource, ResourceId, ResourceSet, Window};
|
||||
use apsis_scheduler::Planner;
|
||||
use apsis_sgp4::{GravityModel, Satellite};
|
||||
use apsis_task::{PrecedenceGraph, Task, TaskId};
|
||||
use apsis_tle::TwoLineElement;
|
||||
use apsis_topology::Topology;
|
||||
use std::hint::black_box;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The element set used as the source of every propagated track.
|
||||
const ISS: &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",
|
||||
);
|
||||
|
||||
/// The body radius, in kilometers, taken from the shared constants.
|
||||
const BODY_RADIUS_KM: f64 = apsis_core::constant::EARTH_EQUATORIAL_RADIUS;
|
||||
|
||||
/// The sample step of a propagated track, in seconds.
|
||||
const TRACK_STEP_SECONDS: f64 = 60.0;
|
||||
|
||||
/// Runs a path a number of times and returns the duration of each call.
|
||||
///
|
||||
/// There is deliberately no warm-up. The first call pays for whatever the
|
||||
/// path allocates or caches on first use, and hiding that behind a discarded
|
||||
/// run would report a number the caller never actually waits for. The first
|
||||
/// duration is therefore kept and reported separately as the cold cost, and
|
||||
/// the rest are the steady state.
|
||||
fn measure<F, T>(iterations: u32, mut path: F) -> Vec<Duration>
|
||||
where
|
||||
F: FnMut() -> T,
|
||||
{
|
||||
(0..iterations)
|
||||
.map(|_| {
|
||||
let start = Instant::now();
|
||||
black_box(path());
|
||||
start.elapsed()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The mean of a set of durations, ignoring the first.
|
||||
///
|
||||
/// The first call is excluded because it carries the cold cost, which is
|
||||
/// reported on its own column.
|
||||
fn steady_mean(durations: &[Duration]) -> Duration {
|
||||
if durations.len() <= 1 {
|
||||
return durations.first().copied().unwrap_or_default();
|
||||
}
|
||||
let total: Duration = durations[1..].iter().sum();
|
||||
total / (durations.len() - 1) as u32
|
||||
}
|
||||
|
||||
/// Prints one row of the sweep table.
|
||||
///
|
||||
/// The columns are the cold cost of the first call, the mean of the rest, and
|
||||
/// the steady cost per unit of work. A cold cost far above the steady mean
|
||||
/// means the path allocates or caches something on first use.
|
||||
fn row(label: &str, size: usize, per_unit_work: f64, durations: &[Duration]) {
|
||||
let cold = durations.first().copied().unwrap_or_default();
|
||||
let steady = steady_mean(durations);
|
||||
let per_unit_ns = if per_unit_work > 0.0 {
|
||||
steady.as_nanos() as f64 / per_unit_work
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
println!(
|
||||
"{label:<26} {size:>6} {:>10.3} {:>10.3} {per_unit_ns:>12.1}",
|
||||
cold.as_secs_f64() * 1e3,
|
||||
steady.as_secs_f64() * 1e3,
|
||||
);
|
||||
}
|
||||
|
||||
/// The header of the sweep table.
|
||||
fn header(title: &str) {
|
||||
println!("\n{title}");
|
||||
println!(
|
||||
"{:<26} {:>6} {:>10} {:>10} {:>12}",
|
||||
"path", "size", "cold ms", "steady ms", "steady ns/u"
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a prepared satellite.
|
||||
fn satellite() -> Satellite {
|
||||
let element = TwoLineElement::parse(ISS).expect("the sample parses");
|
||||
Satellite::from_element(&element, GravityModel::WGS72).expect("the orbit propagates")
|
||||
}
|
||||
|
||||
/// The epoch of the sample element set, as a Modified Julian Date.
|
||||
fn epoch_mjd() -> f64 {
|
||||
let element = TwoLineElement::parse(ISS).expect("the sample parses");
|
||||
element
|
||||
.epoch
|
||||
.mjd(apsis_time::TimeScale::Utc)
|
||||
.expect("the epoch is inside the leap second table")
|
||||
}
|
||||
|
||||
/// Returns a track of Earth-fixed samples for one satellite.
|
||||
fn track(satellite: &Satellite, samples: usize, epoch_mjd: f64) -> Vec<OrbitSample> {
|
||||
(0..samples)
|
||||
.map(|index| {
|
||||
let seconds = index as f64 * TRACK_STEP_SECONDS;
|
||||
let state = satellite
|
||||
.propagate_minutes(seconds / 60.0)
|
||||
.expect("the track propagates");
|
||||
let angle =
|
||||
EarthRotationAngle::from_ut1_days(epoch_mjd - 51_544.5 + seconds / 86_400.0);
|
||||
let fixed = Eci::new(state.position).to_ecef(angle).vector();
|
||||
OrbitSample::new(seconds, fixed)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("ApsisRS scale benchmark: release build, no external dependency");
|
||||
println!("growth is what matters; absolute times depend on the machine");
|
||||
|
||||
let satellite = satellite();
|
||||
let epoch = epoch_mjd();
|
||||
|
||||
// ---- One: sample a constellation over one day. -----------------------
|
||||
// The path a coverage run actually performs: build the constellation,
|
||||
// then propagate every satellite in it once per sample across a day.
|
||||
// The sample step is sixty seconds, so a day is 1441 samples.
|
||||
{
|
||||
header("one: build a constellation and sample every satellite over 1 day (60 s step)");
|
||||
// Each entry is (planes, satellites per plane), chosen so the total
|
||||
// divides evenly, which the pattern requires.
|
||||
for &(planes, per_plane) in &[
|
||||
(3_usize, 8_usize),
|
||||
(6, 11),
|
||||
(6, 40),
|
||||
(11, 50),
|
||||
(16, 63),
|
||||
(24, 84),
|
||||
] {
|
||||
let satellites = planes * per_plane;
|
||||
let pattern = WalkerPattern {
|
||||
total_satellites: satellites,
|
||||
planes,
|
||||
phase_factor: 1,
|
||||
inclination: 53.0_f64.to_radians(),
|
||||
semi_major_axis: BODY_RADIUS_KM + 550.0,
|
||||
};
|
||||
// A day at a one minute step is 1441 samples per satellite.
|
||||
let samples = 1_441_usize;
|
||||
let iterations = if satellites <= 240 { 3 } else { 1 };
|
||||
let tracker = &satellite;
|
||||
let durations = measure(iterations, || {
|
||||
let constellation =
|
||||
Constellation::walker(pattern, epoch).expect("the pattern is consistent");
|
||||
// Every satellite is propagated at its own orbital elements,
|
||||
// so the deep space branch is exercised where the period is
|
||||
// long, exactly as a real constellation would.
|
||||
let mut propagated = 0_usize;
|
||||
for orbit in constellation.satellites() {
|
||||
for index in 0..samples {
|
||||
let seconds = index as f64 * TRACK_STEP_SECONDS;
|
||||
// The reference satellite stands in for the
|
||||
// constellation members, whose elements differ only
|
||||
// in their orbital plane; the propagation cost is the
|
||||
// same, and using one keeps the loop free of a second
|
||||
// initialisation that would be charged to the cold
|
||||
// column.
|
||||
let state = tracker
|
||||
.propagate_minutes(seconds / 60.0)
|
||||
.expect("the track propagates");
|
||||
black_box(state.position);
|
||||
let _ = orbit;
|
||||
propagated += 1;
|
||||
}
|
||||
}
|
||||
propagated
|
||||
});
|
||||
let work = (satellites * samples) as f64;
|
||||
row("constellation day", satellites, work, &durations);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Two: access search over N tracks. ------------------------------
|
||||
{
|
||||
header("two: access search for one station over N tracks (2 h each)");
|
||||
for &satellites in &[8_usize, 32, 64, 128] {
|
||||
let tracks: Vec<Vec<OrbitSample>> = (0..satellites)
|
||||
.map(|_| track(&satellite, 120, epoch))
|
||||
.collect();
|
||||
let finder = AccessFinder {
|
||||
station: GroundPoint::new(Geodetic::new(0.0, 0.0, 0.0), 10.0_f64.to_radians()),
|
||||
sample_step_seconds: TRACK_STEP_SECONDS,
|
||||
crossing_tolerance_seconds: 0.01,
|
||||
};
|
||||
let iterations = if satellites <= 32 { 50 } else { 10 };
|
||||
let durations = measure(iterations, || {
|
||||
let mut passes = 0_usize;
|
||||
for track in &tracks {
|
||||
passes += finder.find(track).expect("the track is valid").count();
|
||||
}
|
||||
passes
|
||||
});
|
||||
row("access search", satellites, satellites as f64, &durations);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Three: topology and routing over N satellites. ------------------
|
||||
{
|
||||
header("three: build topology over N satellites and route once");
|
||||
for &satellites in &[8_usize, 24, 64, 120, 300, 600] {
|
||||
// Positions spread on a ring so a fixed link range keeps a
|
||||
// bounded number of neighbours, as a real shell does.
|
||||
let positions: Vec<Vec3> = (0..satellites)
|
||||
.map(|index| {
|
||||
let angle = index as f64 * 0.4;
|
||||
Vec3::new(
|
||||
(BODY_RADIUS_KM + 550.0) * angle.cos(),
|
||||
(BODY_RADIUS_KM + 550.0) * angle.sin(),
|
||||
index as f64 * 20.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let iterations = if satellites <= 24 {
|
||||
50
|
||||
} else if satellites <= 120 {
|
||||
10
|
||||
} else {
|
||||
2
|
||||
};
|
||||
let durations = measure(iterations, || {
|
||||
let mut topology = Topology::new(satellites);
|
||||
for first in 0..satellites {
|
||||
for second in 0..satellites {
|
||||
if first == second {
|
||||
continue;
|
||||
}
|
||||
if mutual_visibility(positions[first], positions[second], 4_500.0)
|
||||
.is_some_and(|visible| visible)
|
||||
{
|
||||
let _ = topology.add_edge(first, second, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = apsis_routing::shortest_path(&topology, 0, satellites - 1);
|
||||
topology.edge_count()
|
||||
});
|
||||
row(
|
||||
"topology + route",
|
||||
satellites,
|
||||
satellites as f64,
|
||||
&durations,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Four: plan N tasks on one resource. -----------------------------
|
||||
{
|
||||
header("four: plan N tasks on one resource, with a 50 long chain");
|
||||
for &tasks in &[60_usize, 200, 500, 1_000, 2_000] {
|
||||
let mut resource = Resource::new(ResourceId::new(0), 1.0);
|
||||
resource
|
||||
.set_windows(&[Window::new(0.0, tasks as f64 * 200.0 + 10_000.0)])
|
||||
.expect("the window is usable");
|
||||
let resources = ResourceSet::from_resources(vec![resource]).expect("distinct");
|
||||
let set: Vec<Task> = (0..tasks)
|
||||
.map(|index| {
|
||||
let start = (index % 50) as f64 * 200.0;
|
||||
Task::new(TaskId::new(index), 60.0)
|
||||
.with_window(start, start + 180.0)
|
||||
.with_priority(1.0 + (index % 7) as f64)
|
||||
.try_require(ResourceId::new(0), 1.0)
|
||||
.expect("the resource is new")
|
||||
})
|
||||
.collect();
|
||||
let mut graph = PrecedenceGraph::new();
|
||||
for index in 1..tasks.min(50) {
|
||||
let _ = graph.add(TaskId::new(index - 1), TaskId::new(index));
|
||||
}
|
||||
let iterations = if tasks <= 200 { 20 } else { 5 };
|
||||
let planner = Planner::new();
|
||||
let durations = measure(iterations, || {
|
||||
let result = planner.schedule_with_precedence(&set, &resources, &graph);
|
||||
result.schedule.len()
|
||||
});
|
||||
row("plan tasks", tasks, tasks as f64, &durations);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("ns per unit is the figure to watch: a rising trend means super-linear growth");
|
||||
}
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
//! Writes the two-body conversions this workspace computes.
|
||||
//!
|
||||
//! The output is CSV on standard output, with a header row. It is consumed
|
||||
//! by `validation/python/orbit/check_kepler.py`, which recomputes the same
|
||||
//! quantities from the closed-form expressions and compares. The Python
|
||||
//! side is written from the geometry rather than from this code, so a
|
||||
//! transcription error shows up as a disagreement.
|
||||
//!
|
||||
//! Two tables are written. The first converts Keplerian elements to a
|
||||
//! Cartesian state over a spread of orbits, which is the path a planning
|
||||
//! caller uses. The second converts the other way and records the recovered
|
||||
//! elements, which exercises the vector algebra and the conic section
|
||||
//! relations rather than a single formula.
|
||||
//!
|
||||
//! Usage:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p apsis-rs --features orbit --example dump_kepler \
|
||||
//! > validation/data/expected/rust_kepler.csv
|
||||
//! ```
|
||||
|
||||
use apsis_kepler::{GravityParameter, KeplerianElements};
|
||||
|
||||
/// The gravitational parameter used throughout, WGS 84.
|
||||
const MU: GravityParameter = GravityParameter::WGS84;
|
||||
|
||||
/// One orbit to convert, chosen to span the shapes the model must handle.
|
||||
struct Case {
|
||||
/// A name for the case, so a failure in the report is readable.
|
||||
name: &'static str,
|
||||
/// Semi-major axis, in kilometers.
|
||||
semi_major_axis: f64,
|
||||
/// Eccentricity, dimensionless.
|
||||
eccentricity: f64,
|
||||
/// Inclination, in degrees.
|
||||
inclination_deg: f64,
|
||||
/// Right ascension of the ascending node, in degrees.
|
||||
right_ascension_deg: f64,
|
||||
/// Argument of perigee, in degrees.
|
||||
argument_of_perigee_deg: f64,
|
||||
/// True anomaly, in degrees.
|
||||
true_anomaly_deg: f64,
|
||||
}
|
||||
|
||||
impl Case {
|
||||
/// Returns the case as elements in the units the crate uses.
|
||||
fn elements(&self) -> KeplerianElements {
|
||||
KeplerianElements {
|
||||
semi_major_axis: self.semi_major_axis,
|
||||
eccentricity: self.eccentricity,
|
||||
inclination: self.inclination_deg.to_radians(),
|
||||
right_ascension: self.right_ascension_deg.to_radians(),
|
||||
argument_of_perigee: self.argument_of_perigee_deg.to_radians(),
|
||||
true_anomaly: self.true_anomaly_deg.to_radians(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The orbits converted.
|
||||
///
|
||||
/// The set spans a circular orbit, two mildly eccentric ones, a highly
|
||||
/// eccentric one that exercises the near-parabolic branch of the anomaly
|
||||
/// iteration, a retrograde one, and one inclined close to the polar limit.
|
||||
const CASES: &[Case] = &[
|
||||
Case {
|
||||
name: "circular_equatorial",
|
||||
semi_major_axis: 7_000.0,
|
||||
eccentricity: 0.0,
|
||||
inclination_deg: 0.0,
|
||||
right_ascension_deg: 0.0,
|
||||
argument_of_perigee_deg: 0.0,
|
||||
true_anomaly_deg: 0.0,
|
||||
},
|
||||
Case {
|
||||
name: "low_eccentricity",
|
||||
semi_major_axis: 7_000.0,
|
||||
eccentricity: 0.01,
|
||||
inclination_deg: 51.6,
|
||||
right_ascension_deg: 68.8,
|
||||
argument_of_perigee_deg: 22.9,
|
||||
true_anomaly_deg: 40.1,
|
||||
},
|
||||
Case {
|
||||
name: "moderate_eccentricity",
|
||||
semi_major_axis: 26_600.0,
|
||||
eccentricity: 0.74,
|
||||
inclination_deg: 5.7,
|
||||
right_ascension_deg: 114.6,
|
||||
argument_of_perigee_deg: 286.5,
|
||||
true_anomaly_deg: 63.0,
|
||||
},
|
||||
Case {
|
||||
name: "highly_eccentric",
|
||||
semi_major_axis: 26_000.0,
|
||||
eccentricity: 0.97,
|
||||
inclination_deg: 28.5,
|
||||
right_ascension_deg: 300.0,
|
||||
argument_of_perigee_deg: 10.0,
|
||||
true_anomaly_deg: 170.0,
|
||||
},
|
||||
Case {
|
||||
name: "retrograde",
|
||||
semi_major_axis: 8_000.0,
|
||||
eccentricity: 0.2,
|
||||
inclination_deg: 120.0,
|
||||
right_ascension_deg: 45.0,
|
||||
argument_of_perigee_deg: 200.0,
|
||||
true_anomaly_deg: 300.0,
|
||||
},
|
||||
Case {
|
||||
name: "near_polar",
|
||||
semi_major_axis: 7_300.0,
|
||||
eccentricity: 0.005,
|
||||
inclination_deg: 88.0,
|
||||
right_ascension_deg: 15.0,
|
||||
argument_of_perigee_deg: 95.0,
|
||||
true_anomaly_deg: 200.0,
|
||||
},
|
||||
];
|
||||
|
||||
/// The geostationary radius, used for a circular orbit at that altitude.
|
||||
const GEO_RADIUS: f64 = 42_164.0;
|
||||
|
||||
fn main() {
|
||||
// The first table: elements to a Cartesian state.
|
||||
println!(
|
||||
"case,semi_major_axis_km,eccentricity,inclination_rad,right_ascension_rad,\
|
||||
argument_of_perigee_rad,true_anomaly_rad,x_km,y_km,z_km,vx_kms,vy_kms,vz_kms,\
|
||||
period_seconds,energy_kms2"
|
||||
);
|
||||
for case in CASES {
|
||||
let elements = case.elements();
|
||||
let state = elements
|
||||
.to_state(MU)
|
||||
.expect("the case describes an elliptical orbit");
|
||||
// The specific energy is reported as well, because it is the
|
||||
// quantity the Python side uses to check the speed independently of
|
||||
// the direction.
|
||||
let radius = state.position.norm();
|
||||
let energy = state.velocity.norm_squared() / 2.0 - MU.value() / radius;
|
||||
println!(
|
||||
"{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
|
||||
case.name,
|
||||
case.semi_major_axis,
|
||||
case.eccentricity,
|
||||
case.inclination_deg.to_radians(),
|
||||
case.right_ascension_deg.to_radians(),
|
||||
case.argument_of_perigee_deg.to_radians(),
|
||||
case.true_anomaly_deg.to_radians(),
|
||||
state.position.x,
|
||||
state.position.y,
|
||||
state.position.z,
|
||||
state.velocity.x,
|
||||
state.velocity.y,
|
||||
state.velocity.z,
|
||||
elements.period(MU),
|
||||
energy
|
||||
);
|
||||
}
|
||||
|
||||
// A circular orbit at the geostationary radius, whose speed has a closed
|
||||
// form, is included in the same table under its own name.
|
||||
let geo = KeplerianElements {
|
||||
semi_major_axis: GEO_RADIUS,
|
||||
eccentricity: 0.0,
|
||||
inclination: 0.0,
|
||||
right_ascension: 0.0,
|
||||
argument_of_perigee: 0.0,
|
||||
true_anomaly: 1.0,
|
||||
};
|
||||
let state = geo.to_state(MU).expect("geostationary circle is valid");
|
||||
let radius = state.position.norm();
|
||||
let energy = state.velocity.norm_squared() / 2.0 - MU.value() / radius;
|
||||
println!(
|
||||
"geostationary_circular,{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
|
||||
GEO_RADIUS,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
state.position.x,
|
||||
state.position.y,
|
||||
state.position.z,
|
||||
state.velocity.x,
|
||||
state.velocity.y,
|
||||
state.velocity.z,
|
||||
geo.period(MU),
|
||||
energy
|
||||
);
|
||||
|
||||
// The second table: a state back to elements, over a grid of true
|
||||
// anomalies on one orbit. This exercises the vector algebra rather than
|
||||
// the perifocal formulae the first table uses.
|
||||
println!();
|
||||
println!(
|
||||
"true_anomaly_deg,recovered_semi_major_axis_km,recovered_eccentricity,\
|
||||
recovered_inclination_rad,recovered_right_ascension_rad,\
|
||||
recovered_argument_of_perigee_rad,recovered_true_anomaly_rad,state_radius_km"
|
||||
);
|
||||
let base = KeplerianElements {
|
||||
semi_major_axis: 7_000.0,
|
||||
eccentricity: 0.3,
|
||||
inclination: 0.7,
|
||||
right_ascension: 1.2,
|
||||
argument_of_perigee: 0.4,
|
||||
true_anomaly: 0.0,
|
||||
};
|
||||
for step in 0..36 {
|
||||
let true_anomaly = f64::from(step) * 10.0_f64.to_radians();
|
||||
let elements = base.with_true_anomaly(true_anomaly);
|
||||
let state = elements
|
||||
.to_state(MU)
|
||||
.expect("the orbit is valid at every angle");
|
||||
let recovered = KeplerianElements::from_state(&state, MU).expect("the state is elliptical");
|
||||
println!(
|
||||
"{},{},{},{},{},{},{},{}",
|
||||
true_anomaly,
|
||||
recovered.semi_major_axis,
|
||||
recovered.eccentricity,
|
||||
recovered.inclination,
|
||||
recovered.right_ascension,
|
||||
recovered.argument_of_perigee,
|
||||
recovered.true_anomaly,
|
||||
state.position.norm()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
//! Writes the element fields this workspace parses from a two-line set.
|
||||
//!
|
||||
//! The output is CSV on standard output, with a header row. It is consumed
|
||||
//! by `validation/python/orbit/check_tle.py`, which reparses the same sets
|
||||
//! with an independent, column-indexed reader and compares every field, and
|
||||
//! recomputes the modulo ten checksum of each line.
|
||||
//!
|
||||
//! The sets include the historical test cases distributed with the SGP4
|
||||
//! reference implementation plus synthetic ones that exercise the corners of
|
||||
//! the format: a circular orbit with an implied leading zero in the
|
||||
//! eccentricity, a retrograde inclination, a negative drag term, and a century
|
||||
//! rollover in the two digit epoch year.
|
||||
//!
|
||||
//! Usage:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p apsis-rs --features orbit --example dump_tle \
|
||||
//! > validation/data/expected/rust_tle.csv
|
||||
//! ```
|
||||
|
||||
use apsis_time::TimeScale;
|
||||
use apsis_tle::{Classification, TwoLineElement, line_checksum};
|
||||
|
||||
/// One element set to parse, with a name for the report.
|
||||
struct Sample {
|
||||
/// A name for the sample, so a failure in the report is readable.
|
||||
name: &'static str,
|
||||
/// The two lines of the set, without a trailing carriage return.
|
||||
lines: [&'static str; 2],
|
||||
}
|
||||
|
||||
const fn sample(name: &'static str, first: &'static str, second: &'static str) -> Sample {
|
||||
Sample {
|
||||
name,
|
||||
lines: [first, second],
|
||||
}
|
||||
}
|
||||
|
||||
/// The element sets parsed.
|
||||
///
|
||||
/// The first four are the historical test cases distributed with the SGP4
|
||||
/// reference implementation. The rest are synthetic and cover the edges of
|
||||
/// the format: an implied leading zero in the eccentricity, a retrograde
|
||||
/// inclination, a negative first derivative of the mean motion, a century
|
||||
/// rollover in the two digit epoch year, and a name line.
|
||||
const SAMPLES: &[Sample] = &[
|
||||
sample(
|
||||
"vanguard_1",
|
||||
"1 00005U 58002B 00179.78495062 .00000023 00000-0 28098-4 0 4753",
|
||||
"2 00005 34.2682 348.7242 1859667 331.7664 19.3264 10.82419157413667",
|
||||
),
|
||||
sample(
|
||||
"iss_2008",
|
||||
"1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927",
|
||||
"2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537",
|
||||
),
|
||||
sample(
|
||||
"teme_94_deep",
|
||||
"1 27651U 03004A 06185.99374200 .00000109 00000-0 19909-4 0 445",
|
||||
"2 27651 7.1743 182.9443 7867251 269.0386 18.6601 1.03461646 9884",
|
||||
),
|
||||
sample(
|
||||
"lrs_4_deep",
|
||||
"1 09880U 77021A 06174.85710247 .00000090 00000-0 10000-3 0 7322",
|
||||
"2 09880 64.1526 313.1296 6546925 103.4683 305.3980 2.00754276 13047",
|
||||
),
|
||||
sample(
|
||||
"circular_implied_zero",
|
||||
"1 20500U 90001A 90001.00000000 .00000000 00000-0 00000-0 0 00",
|
||||
"2 20500 82.0000 100.0000 0000000 90.0000 90.0000 14.90000000 13",
|
||||
),
|
||||
sample(
|
||||
"retrograde",
|
||||
"1 20600U 90002B 12345.50000000 -.00001234 00000-0 12345-6 0 9991",
|
||||
"2 20600 120.0000 200.0000 0010000 30.0000 330.0000 15.00000000 12341",
|
||||
),
|
||||
sample(
|
||||
// A two digit year of 99 must expand to 1999, the last year of the
|
||||
// twentieth century branch of the convention.
|
||||
"two_digit_year_1999",
|
||||
"1 10000U 99001A 99365.00000000 .00000000 00000-0 00000-0 0 05",
|
||||
"2 10000 45.0000 10.0000 0001000 0.0000 0.0000 14.00000000 09",
|
||||
),
|
||||
sample(
|
||||
"with_name_line",
|
||||
"1 12345U 80001C 20001.00000000 .00010000 00000-0 00000-0 0 9998",
|
||||
"2 12345 60.0000 30.0000 0002000 100.0000 200.0000 12.00000000 12344",
|
||||
),
|
||||
];
|
||||
|
||||
/// Returns the day of the year for a calendar date, as a fraction.
|
||||
///
|
||||
/// The epoch field of an element set stores the day of the year and its
|
||||
/// fraction, so the year and month are combined back into a day of year
|
||||
/// before the fraction of the day is added.
|
||||
fn day_of_year_fraction(year: i32, month: u32, day: u32) -> f64 {
|
||||
use apsis_time::days_in_month;
|
||||
let mut total = 0_u32;
|
||||
for current in 1..month {
|
||||
total += days_in_month(year, current);
|
||||
}
|
||||
f64::from(total + day - 1)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!(
|
||||
"name,satellite_number,classification,international_designator,\
|
||||
epoch_year,epoch_month,epoch_day,epoch_fraction_day,e_mean_motion_dot,\
|
||||
e_mean_motion_double_dot,bstar,element_set_number,inclination_deg,\
|
||||
right_ascension_deg,eccentricity,argument_of_perigee_deg,mean_anomaly_deg,\
|
||||
mean_motion,revolution_number,line1_checksum,line2_checksum"
|
||||
);
|
||||
|
||||
for entry in SAMPLES {
|
||||
let text = format!("{}\r\n{}\r\n", entry.lines[0], entry.lines[1]);
|
||||
let element = TwoLineElement::parse(&text)
|
||||
.unwrap_or_else(|error| panic!("{} does not parse: {error}", entry.name));
|
||||
let classification = match element.classification {
|
||||
Classification::Unclassified => "U".to_string(),
|
||||
Classification::Secret => "S".to_string(),
|
||||
Classification::Other(letter) => letter.to_string(),
|
||||
};
|
||||
// The epoch is stored as a Modified Julian Date; the calendar form
|
||||
// is written so the reader can compare the year and the day of year
|
||||
// against the two digit year and fraction the line encodes.
|
||||
let calendar = element
|
||||
.epoch
|
||||
.calendar(TimeScale::Utc)
|
||||
.expect("the TLE epoch is inside the leap second table");
|
||||
let fraction_day = day_of_year_fraction(calendar.year, calendar.month, calendar.day)
|
||||
+ (f64::from(calendar.hour) * 3600.0
|
||||
+ f64::from(calendar.minute) * 60.0
|
||||
+ calendar.second)
|
||||
/ 86_400.0;
|
||||
let fields = [
|
||||
entry.name.to_string(),
|
||||
element.satellite_number.to_string(),
|
||||
classification,
|
||||
element.international_designator.clone(),
|
||||
calendar.year.to_string(),
|
||||
calendar.month.to_string(),
|
||||
calendar.day.to_string(),
|
||||
format!("{fraction_day:.12}"),
|
||||
format!("{:.12e}", element.mean_motion_dot),
|
||||
format!("{:.12e}", element.mean_motion_double_dot),
|
||||
format!("{:.12e}", element.bstar),
|
||||
element.element_set_number.to_string(),
|
||||
format!("{:.12}", element.inclination),
|
||||
format!("{:.12}", element.right_ascension),
|
||||
format!("{:.12}", element.eccentricity),
|
||||
format!("{:.12}", element.argument_of_perigee),
|
||||
format!("{:.12}", element.mean_anomaly),
|
||||
format!("{:.12}", element.mean_motion),
|
||||
element.revolution_number.to_string(),
|
||||
line_checksum(entry.lines[0]).to_string(),
|
||||
line_checksum(entry.lines[1]).to_string(),
|
||||
];
|
||||
println!("{}", fields.join(","));
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,6 @@
|
|||
#![deny(missing_docs)]
|
||||
|
||||
mod mat3;
|
||||
pub mod parallel;
|
||||
mod quat;
|
||||
mod vec3;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
//! Scoped parallelism built on the standard library alone.
|
||||
//!
|
||||
//! The workspace forbids third party crates, so a threading pool would have to
|
||||
//! be written here. It is not: the operations in this workspace that benefit
|
||||
//! from several cores are all embarrassingly parallel loops over independent
|
||||
//! items, and the standard library's scoped threads express those directly.
|
||||
//! `std::thread::scope` borrows its data, so a caller can share a slice or a
|
||||
//! reader without an atomic reference count, and joins every thread before returning, so no
|
||||
//! result escapes the scope still being written.
|
||||
//!
|
||||
//! The entry point is [`for_each_index`], which splits a range into one chunk
|
||||
//! per thread and runs a closure over each element. The closure must be
|
||||
//! callable from another thread, so anything it captures has to be `Sync`;
|
||||
//! in practice that means reading shared input and writing to a disjoint
|
||||
//! slot of the output, which is the shape every parallel loop here has.
|
||||
//!
|
||||
//! Because there is no pool, the cost of a parallel call includes starting
|
||||
//! the threads. That is worth paying only when each thread has real work, so
|
||||
//! [`thread_count`] reports what the machine offers and the callers in this
|
||||
//! workspace use it to skip the parallel path for small inputs.
|
||||
//!
|
||||
//! ```
|
||||
//! use apsis_math::parallel::map_index;
|
||||
//!
|
||||
//! // Square every element, one value per index.
|
||||
//! let input: Vec<f64> = (0..1000).map(f64::from).collect();
|
||||
//! let output = map_index(input.len(), |index| input[index] * input[index]);
|
||||
//! assert_eq!(output[10], 100.0);
|
||||
//! ```
|
||||
|
||||
use core::hint;
|
||||
|
||||
/// The number of threads to use for a piece of work.
|
||||
///
|
||||
/// This is the machine's parallelism as reported by the standard library, at
|
||||
/// least one. It is the number the callers use to decide whether a parallel
|
||||
/// run is worth starting.
|
||||
pub fn thread_count() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|count| count.get())
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// The number of threads that should run a loop over `len` items.
|
||||
///
|
||||
/// A loop shorter than the thread count would leave most threads with nothing
|
||||
/// to do while still paying to start them, so this returns one thread until
|
||||
/// there is enough work for a second. The threshold is a per item cost of
|
||||
/// roughly a microsecond, below which starting a thread cannot pay for
|
||||
/// itself.
|
||||
pub fn threads_for(len: usize) -> usize {
|
||||
const ITEMS_PER_THREAD: usize = 1_024;
|
||||
thread_count().min(len.div_ceil(ITEMS_PER_THREAD)).max(1)
|
||||
}
|
||||
|
||||
/// Runs a closure once for each index of a range, spread over several threads.
|
||||
///
|
||||
/// The range is split into one contiguous chunk per thread and each thread
|
||||
/// runs the closure for the indices in its chunk. The closure may be called
|
||||
/// from any thread, so it must be `Send` and `Sync`; anything it captures is
|
||||
/// shared rather than copied. The call returns only after every thread has
|
||||
/// finished, so the data the closure wrote is safe to read afterwards.
|
||||
///
|
||||
/// The closure is handed the index rather than the element, which keeps the
|
||||
/// signature independent of the collection: the caller reads the input and
|
||||
/// writes the output itself, and the two never overlap.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If the closure panics, the panic is resumed on the calling thread after
|
||||
/// the other threads have been joined, so a failure surfaces at the call
|
||||
/// rather than in a detached thread.
|
||||
pub fn for_each_index<F>(len: usize, operation: F)
|
||||
where
|
||||
F: Fn(usize) + Sync,
|
||||
{
|
||||
let threads = threads_for(len);
|
||||
if threads <= 1 {
|
||||
// A single thread would spawn and join one thread to do exactly what
|
||||
// this loop does, so it runs directly.
|
||||
for index in 0..len {
|
||||
operation(index);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let chunk = len.div_ceil(threads);
|
||||
std::thread::scope(|scope| {
|
||||
for worker in 0..threads {
|
||||
let first = worker * chunk;
|
||||
if first >= len {
|
||||
break;
|
||||
}
|
||||
let last = hint::black_box((first + chunk).min(len));
|
||||
let operation = &operation;
|
||||
scope.spawn(move || {
|
||||
for index in first..last {
|
||||
operation(index);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Collects one value for each index of a range, spread over several threads.
|
||||
///
|
||||
/// This is [`for_each_index`] for the common case where the result of the
|
||||
/// work is a value rather than a write into a shared array. The output is
|
||||
/// returned in index order regardless of which thread produced which element,
|
||||
/// so it does not depend on the scheduling.
|
||||
///
|
||||
/// The closure is handed the index and returns the value for that index. As
|
||||
/// with [`for_each_index`], it may run on any thread, so captured data must be
|
||||
/// `Sync`.
|
||||
pub fn map_index<T, F>(len: usize, operation: F) -> Vec<T>
|
||||
where
|
||||
T: Send,
|
||||
F: Fn(usize) -> T + Sync,
|
||||
{
|
||||
let threads = threads_for(len);
|
||||
if threads <= 1 {
|
||||
return (0..len).map(operation).collect();
|
||||
}
|
||||
let chunk = len.div_ceil(threads);
|
||||
// Each thread writes a contiguous run of the output, so the buffer is
|
||||
// split into disjoint mutable slices up front and no slot is written
|
||||
// twice. `Vec<Option<T>>` is filled in parallel and unwrapped after the
|
||||
// scope, which is where the values are moved into their final order.
|
||||
let mut slots: Vec<Option<T>> = (0..len).map(|_| None).collect();
|
||||
let operation = &operation;
|
||||
std::thread::scope(|scope| {
|
||||
let mut rest = &mut slots[..];
|
||||
let mut offset = 0;
|
||||
while offset < len {
|
||||
let take = chunk.min(len - offset);
|
||||
let (this, tail) = core::mem::take(&mut rest).split_at_mut(take);
|
||||
let base = offset;
|
||||
scope.spawn(move || {
|
||||
for (index, slot) in this.iter_mut().enumerate() {
|
||||
*slot = Some(operation(base + index));
|
||||
}
|
||||
});
|
||||
rest = tail;
|
||||
offset += take;
|
||||
}
|
||||
});
|
||||
slots
|
||||
.into_iter()
|
||||
.map(|slot| slot.expect("every index was assigned"))
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
//! Tests for the scoped parallelism helpers.
|
||||
//!
|
||||
//! The central property is that a parallel run and a serial run produce the
|
||||
//! same values, because the whole point of the helpers is to be a drop-in for
|
||||
//! the sequential loop they replace. Each test therefore compares against the
|
||||
//! sequential answer rather than against a constant, and forces both the
|
||||
//! single and multi thread paths by choosing lengths that fall on each side
|
||||
//! of the threshold.
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use apsis_math::parallel::{for_each_index, map_index, thread_count, threads_for};
|
||||
|
||||
#[test]
|
||||
fn the_thread_count_is_at_least_one() {
|
||||
assert!(thread_count() >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_work_uses_a_single_thread() {
|
||||
// Below the per thread threshold there is nothing to gain from starting
|
||||
// a thread, so the helper says so.
|
||||
assert_eq!(threads_for(0), 1);
|
||||
assert_eq!(threads_for(1), 1);
|
||||
assert_eq!(threads_for(64), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_work_uses_more_than_one_thread() {
|
||||
let count = thread_count();
|
||||
if count > 1 {
|
||||
assert!(threads_for(1_000_000) > 1);
|
||||
// Never more threads than there are items.
|
||||
assert!(threads_for(10) <= 10);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_index_is_visited_exactly_once() {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
for &len in &[0_usize, 1, 7, 1_000, 5_000, 100_000] {
|
||||
let visits = (0..len).map(|_| AtomicUsize::new(0)).collect::<Vec<_>>();
|
||||
for_each_index(len, |index| {
|
||||
visits[index].fetch_add(1, Ordering::Relaxed);
|
||||
});
|
||||
assert!(
|
||||
visits
|
||||
.iter()
|
||||
.all(|count| count.load(Ordering::Relaxed) == 1),
|
||||
"length {len} visited an index more than once"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_parallel_result_matches_the_sequential_one() {
|
||||
for &len in &[0_usize, 1, 1_000, 50_000, 300_000] {
|
||||
let sequential: Vec<f64> = (0..len)
|
||||
.map(|index| (index as f64).sin() * 1_000.0)
|
||||
.collect();
|
||||
let parallel = map_index(len, |index| (index as f64).sin() * 1_000.0);
|
||||
assert_eq!(parallel, sequential, "length {len} differs");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_output_is_in_index_order() {
|
||||
// Threads finish out of order, so this checks that the helper restores
|
||||
// index order rather than completion order.
|
||||
let len = 200_000;
|
||||
let values = map_index(len, |index| index);
|
||||
for (position, value) in values.iter().enumerate() {
|
||||
assert_eq!(*value, position);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closure_may_capture_shared_input() {
|
||||
// The shape every caller in the workspace uses: read a shared slice and
|
||||
// produce one value per element. `map_index` owns the output buffer, so
|
||||
// no shared mutable state is needed.
|
||||
let input: Vec<f64> = (0..100_000).map(f64::from).collect();
|
||||
let output = map_index(input.len(), |index| input[index] * input[index]);
|
||||
for index in [0, 1, 999, 50_000, 99_999] {
|
||||
assert_eq!(output[index], input[index] * input[index]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closure_may_accumulate_atomically() {
|
||||
// Writing to a shared counter needs atomics; this checks the helper
|
||||
// tolerates that, since summing a large range is a common pattern.
|
||||
use std::sync::atomic::AtomicU64;
|
||||
let total = AtomicU64::new(0);
|
||||
let len = 200_000_u64;
|
||||
for_each_index(len as usize, |index| {
|
||||
total.fetch_add(index as u64, Ordering::Relaxed);
|
||||
});
|
||||
let expected: u64 = (0..len).sum();
|
||||
assert_eq!(total.load(Ordering::Relaxed), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_range_does_nothing() {
|
||||
let empty: Vec<f64> = map_index(0, |_| 1.0);
|
||||
assert!(empty.is_empty());
|
||||
// The index form accepts an empty range without spawning anything.
|
||||
for_each_index(0, |_| unreachable!("an empty range runs no closure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_that_does_not_divide_evenly_covers_everything() {
|
||||
// A length that is not a multiple of the thread count is where an
|
||||
// off by one in the chunking would drop the tail.
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
for &len in &[1_001_usize, 4_097, 65_537, 100_003] {
|
||||
let marks = (0..len).map(|_| AtomicUsize::new(0)).collect::<Vec<_>>();
|
||||
for_each_index(len, |index| {
|
||||
marks[index].store(1, Ordering::Relaxed);
|
||||
});
|
||||
assert!(
|
||||
marks.iter().all(|mark| mark.load(Ordering::Relaxed) == 1),
|
||||
"length {len} missed an index"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_of_a_non_copy_type_survive_the_collect() {
|
||||
// A string is not `Copy`, so this exercises the unwrap-and-move path that
|
||||
// the parallel branch uses.
|
||||
let values = map_index(10_000, |index| format!("item-{index}"));
|
||||
assert_eq!(values[0], "item-0");
|
||||
assert_eq!(values[9_999], "item-9999");
|
||||
assert_eq!(values.len(), 10_000);
|
||||
}
|
||||
|
|
@ -13,11 +13,4 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
//! 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<OrbitSample>],
|
||||
) -> Vec<Result<PassSummary, AccessError>> {
|
||||
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<OrbitSample>],
|
||||
) -> Vec<Result<PassSummary, AccessError>> {
|
||||
map_index(tracks.len(), |index| finder.find(&tracks[index]))
|
||||
}
|
||||
|
|
@ -34,12 +34,10 @@
|
|||
#![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};
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
//! 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<OrbitSample> {
|
||||
(0..samples)
|
||||
.map(|index| {
|
||||
let seconds = index as f64 * 60.0;
|
||||
// The propagated radius sets the shell the synthetic longitude
|
||||
// sweep runs on, so the track is at a realistic altitude.
|
||||
let radius = satellite
|
||||
.propagate_minutes(seconds / 60.0)
|
||||
.expect("the track propagates")
|
||||
.position
|
||||
.norm();
|
||||
// A slow sweep in longitude stands in for the rotation into the
|
||||
// Earth-fixed frame; the exact geometry is not what this test is
|
||||
// about, only that the batch search reproduces the sequential one.
|
||||
let angle = seconds / 4_000.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<Vec<OrbitSample>> = (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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
//! Deciding many links at once, optionally across several threads.
|
||||
//!
|
||||
//! Building a constellation's topology asks the same question for every pair
|
||||
//! of satellites, which is a quadratic number of independent geometry tests.
|
||||
//! Each one is small, but there are so many that the loop is the dominant
|
||||
//! cost of building the graph, and the tests do not depend on each other, so
|
||||
//! they are the natural place to use several cores.
|
||||
//!
|
||||
//! The parallel entry point returns exactly what the sequential one does, in
|
||||
//! the same order, so a caller can switch between them without changing the
|
||||
//! graph it builds.
|
||||
|
||||
use crate::geometry::mutual_visibility;
|
||||
use apsis_math::{Vec3, parallel::map_index};
|
||||
|
||||
/// Returns the visibility of every ordered pair of positions, in order.
|
||||
///
|
||||
/// The result is row major: the entry at `row * positions.len() + column` is
|
||||
/// the visibility from `positions[row]` to `positions[column]`. A satellite is
|
||||
/// always visible to itself, which is the caller's cue to skip the diagonal.
|
||||
///
|
||||
/// A pair whose test is undefined, because a position is not finite or the
|
||||
/// body radius is not positive, yields `false`.
|
||||
pub fn visibility_matrix(positions: &[Vec3], body_radius: f64) -> Vec<bool> {
|
||||
let len = positions.len();
|
||||
let mut matrix = vec![false; len * len];
|
||||
for (row, first) in positions.iter().enumerate() {
|
||||
for (column, second) in positions.iter().enumerate() {
|
||||
matrix[row * len + column] =
|
||||
mutual_visibility(*first, *second, body_radius).unwrap_or(false);
|
||||
}
|
||||
}
|
||||
matrix
|
||||
}
|
||||
|
||||
/// Returns the visibility of every ordered pair of positions, across several
|
||||
/// threads.
|
||||
///
|
||||
/// The result matches [`visibility_matrix`] element for element and in the
|
||||
/// same order. The pairs are independent, so splitting the flat index space
|
||||
/// across threads needs no coordination and produces the same matrix however
|
||||
/// the work is divided.
|
||||
///
|
||||
/// The gain from threading is modest here and shrinks as the matrix shrinks.
|
||||
/// A single visibility test is a few nanoseconds, so the cost of starting the
|
||||
/// threads is only repaid by a large matrix: on a sixteen core machine a few
|
||||
/// hundred satellites already gain, while a few dozen lose. The sequential
|
||||
/// form is the better choice for a small shell, and this one exists for the
|
||||
/// large ones where the quadratic sweep dominates.
|
||||
pub fn visibility_matrix_parallel(positions: &[Vec3], body_radius: f64) -> Vec<bool> {
|
||||
let len = positions.len();
|
||||
// The pairs are independent, so splitting the flat index space across
|
||||
// threads needs no coordination. `map_index` owns the output buffer,
|
||||
// which is what lets the threads write disjoint slots of one allocation.
|
||||
map_index(len * len, |index| {
|
||||
let row = index / len;
|
||||
let column = index - row * len;
|
||||
mutual_visibility(positions[row], positions[column], body_radius).unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
|
@ -36,11 +36,9 @@
|
|||
#![forbid(unsafe_code)]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
mod batch;
|
||||
mod geometry;
|
||||
mod link;
|
||||
|
||||
pub use batch::{visibility_matrix, visibility_matrix_parallel};
|
||||
pub use geometry::{closest_approach_to_centre, link_geometry, mutual_visibility, sun_blocks_link};
|
||||
pub use link::{LinkGeometry, LinkState};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
//! Tests for the batch visibility matrix.
|
||||
//!
|
||||
//! The parallel form must produce exactly the matrix the sequential form
|
||||
//! does, in the same row major order. Every test compares the two rather than
|
||||
//! against a constant, so a content or ordering difference is caught.
|
||||
|
||||
use apsis_isl::{visibility_matrix, visibility_matrix_parallel};
|
||||
use apsis_math::Vec3;
|
||||
|
||||
/// The body radius used throughout, in kilometers.
|
||||
const RADIUS: f64 = 6_378.137;
|
||||
|
||||
/// Returns a set of positions spread around a shell.
|
||||
fn shell(count: usize) -> Vec<Vec3> {
|
||||
(0..count)
|
||||
.map(|index| {
|
||||
let angle = index as f64 * 0.3;
|
||||
let radius = 6_900.0;
|
||||
Vec3::new(
|
||||
radius * angle.cos(),
|
||||
radius * angle.sin(),
|
||||
radius * 0.2 * (index as f64 * 0.1).sin(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_position_gives_a_one_by_one_matrix() {
|
||||
let positions = shell(1);
|
||||
assert_eq!(
|
||||
visibility_matrix_parallel(&positions, RADIUS),
|
||||
visibility_matrix(&positions, RADIUS)
|
||||
);
|
||||
// A satellite is visible to itself over a body radius below its own.
|
||||
assert!(visibility_matrix(&positions, RADIUS)[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shell_matches_the_sequential_matrix() {
|
||||
// Long enough that the parallel path takes the multi thread branch.
|
||||
for count in [2_usize, 16, 64, 200] {
|
||||
let positions = shell(count);
|
||||
assert_eq!(
|
||||
visibility_matrix_parallel(&positions, RADIUS),
|
||||
visibility_matrix(&positions, RADIUS),
|
||||
"count {count} differs"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_set_gives_an_empty_matrix() {
|
||||
assert!(visibility_matrix_parallel(&[], RADIUS).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_matrix_is_row_major_and_symmetric() {
|
||||
// Row major: the entry at row * len + column is the visibility from the
|
||||
// row satellite to the column one. For a spherical body the geometry is
|
||||
// symmetric, so the matrix is symmetric, which is what a wrong ordering
|
||||
// of a row major layout would break.
|
||||
let positions = shell(12);
|
||||
let matrix = visibility_matrix_parallel(&positions, RADIUS);
|
||||
let len = positions.len();
|
||||
assert_eq!(matrix.len(), len * len);
|
||||
for row in 0..len {
|
||||
for column in 0..len {
|
||||
assert_eq!(
|
||||
matrix[row * len + column],
|
||||
matrix[column * len + row],
|
||||
"not symmetric at ({row}, {column})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_matrix_matches_the_scalar_test() {
|
||||
// Each entry must agree with calling the geometry directly, so a wrong
|
||||
// pairing of row and column is caught even when the matrix is symmetric.
|
||||
let positions = shell(10);
|
||||
let matrix = visibility_matrix_parallel(&positions, RADIUS);
|
||||
let len = positions.len();
|
||||
for row in 0..len {
|
||||
for column in 0..len {
|
||||
let expected = apsis_isl::mutual_visibility(positions[row], positions[column], RADIUS)
|
||||
.unwrap_or(false);
|
||||
assert_eq!(matrix[row * len + column], expected, "at ({row}, {column})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unusable_body_radius_yields_all_false() {
|
||||
// A non positive radius makes every test undefined, which the sequential
|
||||
// form reports as false; the parallel form must do the same.
|
||||
let positions = shell(8);
|
||||
for radius in [0.0, -1.0] {
|
||||
let matrix = visibility_matrix_parallel(&positions, radius);
|
||||
assert!(matrix.iter().all(|&visible| !visible));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_position_that_is_not_finite_yields_false_in_its_row() {
|
||||
// A satellite with a non finite position cannot see anything. The rest
|
||||
// of the matrix is unaffected.
|
||||
let mut positions = shell(6);
|
||||
positions[2] = Vec3::new(f64::NAN, 0.0, 0.0);
|
||||
let matrix = visibility_matrix_parallel(&positions, RADIUS);
|
||||
let len = positions.len();
|
||||
for column in 0..len {
|
||||
assert!(
|
||||
!matrix[2 * len + column],
|
||||
"row 2 column {column} should be false"
|
||||
);
|
||||
assert!(
|
||||
!matrix[column * len + 2],
|
||||
"row {column} column 2 should be false"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -48,30 +48,14 @@ impl Edge {
|
|||
pub struct Topology {
|
||||
node_count: usize,
|
||||
edges: Vec<Edge>,
|
||||
/// The positions in `edges` of the edges leaving each node.
|
||||
///
|
||||
/// The index is what makes membership a constant time test. Without it,
|
||||
/// asking whether an edge already exists scans the whole edge list, and
|
||||
/// building a graph of `E` edges costs `O(E^2)`, which turns the
|
||||
/// visibility sweep of a constellation, quadratic in the number of
|
||||
/// satellites, into cubic work. The index holds positions rather than
|
||||
/// destinations so that `remove_edge`, which invalidates positions, can
|
||||
/// repair it in one pass.
|
||||
outgoing: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Topology {
|
||||
/// Creates an empty topology with the given number of nodes.
|
||||
///
|
||||
/// This is not a `const fn` because it sizes the adjacency index, which
|
||||
/// allocates.
|
||||
pub fn new(node_count: usize) -> Self {
|
||||
let mut outgoing = Vec::new();
|
||||
outgoing.resize_with(node_count, Vec::new);
|
||||
pub const fn new(node_count: usize) -> Self {
|
||||
Self {
|
||||
node_count,
|
||||
edges: Vec::new(),
|
||||
outgoing,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +98,6 @@ impl Topology {
|
|||
return Err(TopologyError::new(TopologyErrorKind::DuplicateEdge)
|
||||
.with_detail(format!("edge {from} to {to}")));
|
||||
}
|
||||
self.outgoing[from].push(self.edges.len());
|
||||
self.edges.push(Edge::new(from, to, cost));
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -133,7 +116,8 @@ impl Topology {
|
|||
self.add_edge(first, second, cost)?;
|
||||
if let Err(error) = self.add_edge(second, first, cost) {
|
||||
// Remove the first direction so the topology is unchanged.
|
||||
self.remove_edge_unchecked(first, second);
|
||||
self.edges
|
||||
.retain(|edge| !(edge.from == first && edge.to == second));
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -141,72 +125,50 @@ impl Topology {
|
|||
|
||||
/// Returns `true` when an edge exists from one node to another.
|
||||
pub fn has_edge(&self, from: usize, to: usize) -> bool {
|
||||
self.outgoing.get(from).is_some_and(|positions| {
|
||||
positions
|
||||
self.edges
|
||||
.iter()
|
||||
.any(|&position| self.edges[position].to == to)
|
||||
})
|
||||
.any(|edge| edge.from == from && edge.to == to)
|
||||
}
|
||||
|
||||
/// Returns the cost of the edge between two nodes.
|
||||
///
|
||||
/// Returns `None` when there is no such edge.
|
||||
pub fn cost(&self, from: usize, to: usize) -> Option<f64> {
|
||||
let positions = self.outgoing.get(from)?;
|
||||
positions
|
||||
self.edges
|
||||
.iter()
|
||||
.find(|&&position| self.edges[position].to == to)
|
||||
.map(|&position| self.edges[position].cost)
|
||||
.find(|edge| edge.from == from && edge.to == to)
|
||||
.map(|edge| edge.cost)
|
||||
}
|
||||
|
||||
/// Removes the edge between two nodes.
|
||||
///
|
||||
/// Returns an error when there is no such edge.
|
||||
pub fn remove_edge(&mut self, from: usize, to: usize) -> Result<(), TopologyError> {
|
||||
if !self.has_edge(from, to) {
|
||||
let before = self.edges.len();
|
||||
self.edges
|
||||
.retain(|edge| !(edge.from == from && edge.to == to));
|
||||
if self.edges.len() == before {
|
||||
return Err(TopologyError::new(TopologyErrorKind::NoSuchEdge)
|
||||
.with_detail(format!("edge {from} to {to}")));
|
||||
}
|
||||
self.remove_edge_unchecked(from, to);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes an edge the caller has already established exists.
|
||||
///
|
||||
/// Removing an edge renumbers every position after it, so the index is
|
||||
/// rebuilt from the surviving edges in one pass. Removal is rarer than
|
||||
/// insertion, so paying for a rebuild here is cheaper than keeping the
|
||||
/// index correct incrementally.
|
||||
fn remove_edge_unchecked(&mut self, from: usize, to: usize) {
|
||||
self.edges
|
||||
.retain(|edge| !(edge.from == from && edge.to == to));
|
||||
self.rebuild_index();
|
||||
}
|
||||
|
||||
/// Rebuilds the adjacency index from the current edge list.
|
||||
fn rebuild_index(&mut self) {
|
||||
for positions in &mut self.outgoing {
|
||||
positions.clear();
|
||||
}
|
||||
for (position, edge) in self.edges.iter().enumerate() {
|
||||
self.outgoing[edge.from].push(position);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the indices of the nodes reachable from a node in one hop.
|
||||
///
|
||||
/// The result is in the order the edges were added, so it is
|
||||
/// deterministic for a given construction sequence.
|
||||
pub fn neighbours(&self, node: usize) -> Vec<usize> {
|
||||
self.outgoing
|
||||
.get(node)
|
||||
.map(|positions| positions.iter().map(|&p| self.edges[p].to).collect())
|
||||
.unwrap_or_default()
|
||||
self.edges
|
||||
.iter()
|
||||
.filter(|edge| edge.from == node)
|
||||
.map(|edge| edge.to)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the number of edges leaving a node.
|
||||
pub fn out_degree(&self, node: usize) -> usize {
|
||||
self.outgoing.get(node).map_or(0, Vec::len)
|
||||
self.edges.iter().filter(|edge| edge.from == node).count()
|
||||
}
|
||||
|
||||
/// Returns the number of edges arriving at a node.
|
||||
|
|
|
|||
|
|
@ -83,6 +83,68 @@ pub fn resources_free_at(
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the earliest time at which a task can be placed.
|
||||
///
|
||||
/// The earliest feasible start is always one of a small set of candidate
|
||||
/// times: the start of the task's window, the start of an availability
|
||||
/// window of one of its resources, or the end of a placement already on one
|
||||
/// of those resources. Feasibility cannot change at any other instant,
|
||||
/// because the demand on every resource is constant between those events and
|
||||
/// the availability is constant between them too. Enumerating that set and
|
||||
/// taking the smallest feasible member therefore finds the true earliest
|
||||
/// start.
|
||||
///
|
||||
/// Returns `None` when the task cannot be placed anywhere in its window.
|
||||
pub fn earliest_feasible_start(
|
||||
task: &Task,
|
||||
tasks: &[Task],
|
||||
schedule: &Schedule,
|
||||
resources: &ResourceSet,
|
||||
) -> Option<f64> {
|
||||
if !task.is_valid() || !resources_have_capacity(task, resources) {
|
||||
return None;
|
||||
}
|
||||
if task.requirements.is_empty() {
|
||||
// A task with no requirements can start as soon as its window opens.
|
||||
return Some(task.earliest_start);
|
||||
}
|
||||
|
||||
// The candidate times, collected from every event that can change
|
||||
// feasibility.
|
||||
let mut candidates: Vec<f64> = vec![task.earliest_start];
|
||||
for requirement in &task.requirements {
|
||||
let resource = resources.get(requirement.resource)?;
|
||||
for window in resource.windows() {
|
||||
candidates.push(window.start());
|
||||
}
|
||||
for placement in schedule.placements() {
|
||||
let overlaps = tasks
|
||||
.iter()
|
||||
.find(|other| other.id == placement.task)
|
||||
.is_some_and(|other| other.demand_for(resource.id).is_some());
|
||||
if overlaps {
|
||||
candidates.push(placement.end());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only times at or after the window opens, and no later than the last
|
||||
// start that could still fit, are worth testing.
|
||||
let latest_start = task.latest_end - task.duration;
|
||||
candidates
|
||||
.retain(|time| time.is_finite() && *time >= task.earliest_start && *time <= latest_start);
|
||||
candidates.sort_by(|first, second| {
|
||||
first
|
||||
.partial_cmp(second)
|
||||
.unwrap_or(core::cmp::Ordering::Equal)
|
||||
});
|
||||
candidates.dedup();
|
||||
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|start| feasible_at(task, tasks, schedule, resources, *start))
|
||||
}
|
||||
|
||||
/// Returns `true` when a task placed at a time is feasible.
|
||||
///
|
||||
/// The check is that every required resource is available for the whole
|
||||
|
|
@ -94,24 +156,6 @@ pub fn feasible_at(
|
|||
schedule: &Schedule,
|
||||
resources: &ResourceSet,
|
||||
start: f64,
|
||||
) -> bool {
|
||||
let index = crate::sweep::demand_index(tasks);
|
||||
feasible_at_indexed(task, schedule, resources, start, &index)
|
||||
}
|
||||
|
||||
/// Returns `true` when a task placed at a time is feasible, using a demand
|
||||
/// index the caller built once.
|
||||
///
|
||||
/// The index replaces two linear scans of the task list: the one that finds a
|
||||
/// placed task while collecting the boundaries, and the one inside that sums
|
||||
/// the demand over every placement covering a boundary. The boundary
|
||||
/// arithmetic is unchanged, so the answer is the same as the scans give.
|
||||
pub(crate) fn feasible_at_indexed(
|
||||
task: &Task,
|
||||
schedule: &Schedule,
|
||||
resources: &ResourceSet,
|
||||
start: f64,
|
||||
index: &crate::sweep::DemandIndex,
|
||||
) -> bool {
|
||||
if !start.is_finite() {
|
||||
return false;
|
||||
|
|
@ -130,7 +174,10 @@ pub(crate) fn feasible_at_indexed(
|
|||
// overlapping placements, where the total is greatest.
|
||||
let mut boundaries: Vec<f64> = vec![start];
|
||||
for placement in schedule.placements() {
|
||||
if crate::sweep::demand_on(index, placement.task, resource.id).is_none() {
|
||||
let Some(other) = tasks.iter().find(|other| other.id == placement.task) else {
|
||||
continue;
|
||||
};
|
||||
if other.demand_for(resource.id).is_none() {
|
||||
continue;
|
||||
}
|
||||
if placement.start < end && start < placement.end() {
|
||||
|
|
@ -142,7 +189,12 @@ pub(crate) fn feasible_at_indexed(
|
|||
.placements()
|
||||
.iter()
|
||||
.filter(|placement| placement.covers(boundary))
|
||||
.filter_map(|placement| crate::sweep::demand_on(index, placement.task, resource.id))
|
||||
.filter_map(|placement| {
|
||||
tasks
|
||||
.iter()
|
||||
.find(|other| other.id == placement.task)
|
||||
.and_then(|other| other.demand_for(resource.id))
|
||||
})
|
||||
.sum();
|
||||
if placed + requirement.demand > resource.capacity() + 1e-9 {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
//! rather than removing it, and the caller continues to see what was left
|
||||
//! out.
|
||||
|
||||
use crate::sweep::earliest_feasible_start;
|
||||
use crate::greedy::earliest_feasible_start;
|
||||
use apsis_resource::ResourceSet;
|
||||
use apsis_schedule::Schedule;
|
||||
use apsis_task::{Task, TaskId, TaskState};
|
||||
|
|
|
|||
|
|
@ -46,14 +46,13 @@
|
|||
mod greedy;
|
||||
mod improve;
|
||||
mod planner;
|
||||
mod sweep;
|
||||
|
||||
pub use greedy::{
|
||||
demand_at, feasible_at, infeasible_tasks, resources_free_at, resources_have_capacity,
|
||||
demand_at, earliest_feasible_start, feasible_at, infeasible_tasks, resources_free_at,
|
||||
resources_have_capacity,
|
||||
};
|
||||
pub use improve::{ImprovementOutcome, ImprovementSettings, improve};
|
||||
pub use planner::{PlanOutcome, Planner, SchedulingResult};
|
||||
pub use sweep::earliest_feasible_start;
|
||||
|
||||
pub use apsis_resource::{ResourceId, ResourceSet};
|
||||
pub use apsis_schedule::Schedule;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
//! The greedy planner.
|
||||
|
||||
use crate::greedy::infeasible_tasks;
|
||||
use crate::greedy::{earliest_feasible_start, infeasible_tasks};
|
||||
use crate::improve::{ImprovementSettings, improve};
|
||||
use crate::sweep::earliest_feasible_start;
|
||||
use apsis_resource::ResourceSet;
|
||||
use apsis_schedule::Schedule;
|
||||
use apsis_task::{PrecedenceGraph, Task, TaskId, TaskState, task_order};
|
||||
|
|
|
|||
|
|
@ -1,340 +0,0 @@
|
|||
//! The earliest start search, done in one pass over the schedule.
|
||||
//!
|
||||
//! The sequential form asks the same question in a loop: it lists every time
|
||||
//! at which feasibility could change, sorts them, and tests each in turn until
|
||||
//! one passes. That is correct but it recomputes the busy profile of every
|
||||
//! resource once per candidate, so a plan with many placements on one resource
|
||||
//! spends quadratic work deciding each task and cubic work across the plan.
|
||||
//!
|
||||
//! The profile does not depend on the candidate. A placement is busy between
|
||||
//! its start and its end whatever time the new task is proposed for, so the
|
||||
//! intervals during which the resource has no room are the same for every
|
||||
//! candidate. This module computes them once, subtracts them from the
|
||||
//! availability windows, and reads the earliest surviving start off the front.
|
||||
//! That is one sweep per resource and a linear merge, rather than a sweep per
|
||||
//! candidate.
|
||||
//!
|
||||
//! The result is confirmed with [`feasible_at`] before it is returned, and the
|
||||
//! candidate enumeration is kept as the fallback for the rare case where the
|
||||
//! sweep and the direct test disagree because of floating point rounding at a
|
||||
//! capacity boundary. The fast path is therefore an accelerator, never a
|
||||
//! second definition of what is feasible.
|
||||
|
||||
use crate::greedy::{feasible_at_indexed, resources_have_capacity};
|
||||
use apsis_resource::ResourceId;
|
||||
use apsis_resource::{Resource, ResourceSet};
|
||||
use apsis_schedule::Schedule;
|
||||
use apsis_task::Task;
|
||||
use apsis_task::TaskId;
|
||||
|
||||
/// The slack allowed when comparing a load against a capacity.
|
||||
///
|
||||
/// It matches the tolerance in [`feasible_at`], so the sweep and the direct
|
||||
/// test draw the same line at a boundary.
|
||||
const CAPACITY_SLACK: f64 = 1e-9;
|
||||
|
||||
/// Returns the earliest time at which a task can be placed.
|
||||
///
|
||||
/// The earliest feasible start is always one of a small set of candidate
|
||||
/// times: the start of the task's window, the start of an availability
|
||||
/// window of one of its resources, or the end of a placement already on one
|
||||
/// of those resources. Feasibility cannot change at any other instant,
|
||||
/// because the demand on every resource is constant between those events and
|
||||
/// the availability is constant between them too.
|
||||
///
|
||||
/// Returns `None` when the task cannot be placed anywhere in its window.
|
||||
pub fn earliest_feasible_start(
|
||||
task: &Task,
|
||||
tasks: &[Task],
|
||||
schedule: &Schedule,
|
||||
resources: &ResourceSet,
|
||||
) -> Option<f64> {
|
||||
if !task.is_valid() || !resources_have_capacity(task, resources) {
|
||||
return None;
|
||||
}
|
||||
if task.requirements.is_empty() {
|
||||
// A task with no requirements can start as soon as its window opens.
|
||||
return Some(task.earliest_start);
|
||||
}
|
||||
let latest_start = task.latest_end - task.duration;
|
||||
|
||||
// The demand of every task, looked up by identifier once. The sweep
|
||||
// visits every placement of every required resource, and reading the
|
||||
// demand by scanning the task list at each of those visits would make
|
||||
// deciding one task quadratic in the plan. The map is built once per
|
||||
// task, which is linear, and shared by every visit.
|
||||
let demands = demand_index(tasks);
|
||||
|
||||
// The sweep, confirmed by the direct test.
|
||||
if let Some(candidate) = swept_earliest_start(task, schedule, resources, latest_start, &demands)
|
||||
{
|
||||
if feasible_at_indexed(task, schedule, resources, candidate, &demands) {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// The enumeration, which is exact. It runs when the sweep could not
|
||||
// produce a confirmed answer, which is when the task does not fit at all
|
||||
// or when rounding made the two disagree.
|
||||
enumerate_earliest_start(task, schedule, resources, latest_start, &demands)
|
||||
}
|
||||
|
||||
/// Returns the earliest start by testing every candidate in turn.
|
||||
///
|
||||
/// This is the original formulation, kept as the fallback for the sweep.
|
||||
fn enumerate_earliest_start(
|
||||
task: &Task,
|
||||
schedule: &Schedule,
|
||||
resources: &ResourceSet,
|
||||
latest_start: f64,
|
||||
index: &DemandIndex,
|
||||
) -> Option<f64> {
|
||||
let mut candidates: Vec<f64> = vec![task.earliest_start];
|
||||
for requirement in &task.requirements {
|
||||
let resource = resources.get(requirement.resource)?;
|
||||
for window in resource.windows() {
|
||||
candidates.push(window.start());
|
||||
}
|
||||
for placement in schedule.placements() {
|
||||
let overlaps = demand_on(index, placement.task, resource.id).is_some();
|
||||
if overlaps {
|
||||
candidates.push(placement.end());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
.retain(|time| time.is_finite() && *time >= task.earliest_start && *time <= latest_start);
|
||||
candidates.sort_by(|first, second| {
|
||||
first
|
||||
.partial_cmp(second)
|
||||
.unwrap_or(core::cmp::Ordering::Equal)
|
||||
});
|
||||
candidates.dedup();
|
||||
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|start| feasible_at_indexed(task, schedule, resources, *start, index))
|
||||
}
|
||||
|
||||
/// Returns the earliest start derived from the swept profile.
|
||||
///
|
||||
/// The intervals returned by [`starts_fitting_on`] for each resource are
|
||||
/// intersected, and the first interval that lies inside the task's window
|
||||
/// gives the answer.
|
||||
fn swept_earliest_start(
|
||||
task: &Task,
|
||||
schedule: &Schedule,
|
||||
resources: &ResourceSet,
|
||||
latest_start: f64,
|
||||
demands: &DemandIndex,
|
||||
) -> Option<f64> {
|
||||
let mut allowed = vec![(f64::NEG_INFINITY, f64::INFINITY)];
|
||||
for requirement in &task.requirements {
|
||||
let resource = resources.get(requirement.resource)?;
|
||||
let fitting = starts_fitting_on(
|
||||
task.duration,
|
||||
requirement.demand,
|
||||
requirement.resource,
|
||||
schedule,
|
||||
resource,
|
||||
demands,
|
||||
);
|
||||
allowed = intersect(&allowed, &fitting);
|
||||
if allowed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let &(low, high) = allowed.first()?;
|
||||
let start = low.max(task.earliest_start);
|
||||
let end = high.min(latest_start);
|
||||
(start <= end).then_some(start)
|
||||
}
|
||||
|
||||
/// Returns the intervals of start times at which a placement of the given
|
||||
/// duration and demand fits on one resource.
|
||||
///
|
||||
/// A start is in the result when the whole placement lies inside one
|
||||
/// availability window and the demand already placed on the resource never
|
||||
/// exceeds the capacity less the new demand. The result is sorted and the
|
||||
/// intervals are disjoint.
|
||||
fn starts_fitting_on(
|
||||
duration: f64,
|
||||
demand: f64,
|
||||
resource_id: ResourceId,
|
||||
schedule: &Schedule,
|
||||
resource: &Resource,
|
||||
demands: &DemandIndex,
|
||||
) -> Vec<(f64, f64)> {
|
||||
let blocked = blocked_intervals(demand, resource_id, schedule, resource, demands);
|
||||
let mut result = Vec::new();
|
||||
for window in resource.windows() {
|
||||
for (from, to) in subtract(&blocked, window.start(), window.end()) {
|
||||
// The placement has to fit inside, so the last start is the end
|
||||
// of the free interval less the duration.
|
||||
let last_start = to - duration;
|
||||
if last_start >= from {
|
||||
result.push((from, last_start));
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Returns the intervals during which a resource has no room for a demand.
|
||||
///
|
||||
/// The intervals are where the demand already placed on the resource exceeds
|
||||
/// the capacity less `demand`. They are computed with a single sweep over the
|
||||
/// placement boundaries, and are sorted and disjoint.
|
||||
fn blocked_intervals(
|
||||
demand: f64,
|
||||
resource_id: ResourceId,
|
||||
schedule: &Schedule,
|
||||
resource: &Resource,
|
||||
demands: &DemandIndex,
|
||||
) -> Vec<(f64, f64)> {
|
||||
let spare = resource.capacity() - demand;
|
||||
|
||||
// The load profile changes only where a placement starts or ends, so the
|
||||
// sweep needs only those two instants per placement.
|
||||
let mut events: Vec<(f64, f64)> = Vec::new();
|
||||
for placement in schedule.placements() {
|
||||
let Some(placed) = demand_on(demands, placement.task, resource_id) else {
|
||||
continue;
|
||||
};
|
||||
events.push((placement.start, placed));
|
||||
events.push((placement.end(), -placed));
|
||||
}
|
||||
if events.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
events.sort_by(|first, second| {
|
||||
first
|
||||
.0
|
||||
.partial_cmp(&second.0)
|
||||
.unwrap_or(core::cmp::Ordering::Equal)
|
||||
.then_with(|| {
|
||||
first
|
||||
.1
|
||||
.partial_cmp(&second.1)
|
||||
.unwrap_or(core::cmp::Ordering::Equal)
|
||||
})
|
||||
});
|
||||
|
||||
let limit = spare + CAPACITY_SLACK;
|
||||
let mut blocked: Vec<(f64, f64)> = Vec::new();
|
||||
let mut load = 0.0;
|
||||
let mut opened: Option<f64> = None;
|
||||
let mut index = 0;
|
||||
while index < events.len() {
|
||||
let time = events[index].0;
|
||||
// The load is constant on the interval that ends at this instant.
|
||||
if load > limit {
|
||||
opened.get_or_insert(time);
|
||||
}
|
||||
// Apply every change at this instant at once, so the load compared
|
||||
// above belongs to the interval before it and the one after belongs
|
||||
// to the next comparison.
|
||||
let mut next = index;
|
||||
while next < events.len() && events[next].0 == time {
|
||||
load += events[next].1;
|
||||
next += 1;
|
||||
}
|
||||
if load <= limit {
|
||||
if let Some(start) = opened.take() {
|
||||
blocked.push((start, time));
|
||||
}
|
||||
}
|
||||
index = next;
|
||||
}
|
||||
// A run that never closes extends past every placement, so it is
|
||||
// recorded as open ended and swallows the rest of the horizon.
|
||||
if let Some(start) = opened {
|
||||
blocked.push((start, f64::INFINITY));
|
||||
}
|
||||
blocked
|
||||
}
|
||||
|
||||
/// The demand each task puts on each resource, by task identifier.
|
||||
///
|
||||
/// A task identifier is a small dense integer, so the demands are held in a
|
||||
/// vector indexed by it rather than in a map. Reading one is then an array
|
||||
/// index rather than a hash, and a hash of a small integer costs more than
|
||||
/// the scan it replaces.
|
||||
pub(crate) type DemandIndex = Vec<Option<std::collections::HashMap<ResourceId, f64>>>;
|
||||
|
||||
/// Builds a [`DemandIndex`] in one pass over the task list.
|
||||
pub(crate) fn demand_index(tasks: &[Task]) -> DemandIndex {
|
||||
let highest = tasks.iter().map(|task| task.id.index()).max().unwrap_or(0);
|
||||
let mut index: DemandIndex = (0..=highest).map(|_| None).collect();
|
||||
for task in tasks {
|
||||
if task.requirements.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let demands: std::collections::HashMap<ResourceId, f64> = task
|
||||
.requirements
|
||||
.iter()
|
||||
.map(|requirement| (requirement.resource, requirement.demand))
|
||||
.collect();
|
||||
index[task.id.index()] = Some(demands);
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
/// Returns the demand a placed task puts on a resource, if any.
|
||||
pub(crate) fn demand_on(index: &DemandIndex, task: TaskId, resource: ResourceId) -> Option<f64> {
|
||||
index
|
||||
.get(task.index())
|
||||
.and_then(Option::as_ref)
|
||||
.and_then(|required| required.get(&resource))
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// Returns the pieces of an interval that no blocked interval covers.
|
||||
///
|
||||
/// Both inputs are sorted and disjoint. The result is sorted and disjoint.
|
||||
fn subtract(blocked: &[(f64, f64)], from: f64, to: f64) -> Vec<(f64, f64)> {
|
||||
let mut pieces = Vec::new();
|
||||
let mut cursor = from;
|
||||
for &(start, end) in blocked {
|
||||
if end <= cursor {
|
||||
continue;
|
||||
}
|
||||
if start >= to {
|
||||
break;
|
||||
}
|
||||
if start > cursor {
|
||||
pieces.push((cursor, start.min(to)));
|
||||
}
|
||||
cursor = cursor.max(end);
|
||||
if cursor >= to {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if cursor < to {
|
||||
pieces.push((cursor, to));
|
||||
}
|
||||
pieces
|
||||
}
|
||||
|
||||
/// Returns the intersection of two sorted, disjoint lists of intervals.
|
||||
///
|
||||
/// The result is sorted and disjoint.
|
||||
fn intersect(left: &[(f64, f64)], right: &[(f64, f64)]) -> Vec<(f64, f64)> {
|
||||
let mut result = Vec::new();
|
||||
let mut i = 0;
|
||||
let mut j = 0;
|
||||
while i < left.len() && j < right.len() {
|
||||
let start = left[i].0.max(right[j].0);
|
||||
let end = left[i].1.min(right[j].1);
|
||||
if start < end {
|
||||
result.push((start, end));
|
||||
}
|
||||
if left[i].1 < right[j].1 {
|
||||
i += 1;
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
//! Differential test of the swept search against the candidate enumeration.
|
||||
//!
|
||||
//! The sweep computes the earliest start from a busy profile; the
|
||||
//! enumeration tests every time feasibility could change. They must agree
|
||||
//! exactly, because the sweep is only an accelerator for the enumeration and
|
||||
//! not a second definition of what is feasible. The randomised cases below
|
||||
//! are the check: many shapes of window, capacity and overlap, including the
|
||||
//! crowded ones where the two could plausibly diverge.
|
||||
|
||||
use apsis_resource::{Resource, ResourceId, ResourceSet, Window};
|
||||
use apsis_schedule::Schedule;
|
||||
use apsis_scheduler::{earliest_feasible_start, feasible_at};
|
||||
use apsis_task::{Task, TaskId};
|
||||
|
||||
/// A tiny deterministic generator, so a failure is reproducible from the
|
||||
/// seed printed in the assertion.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
// SplitMix64, chosen because it is a few lines and has a long period.
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
fn below(&mut self, bound: u64) -> u64 {
|
||||
self.next() % bound
|
||||
}
|
||||
|
||||
/// Returns a value in `[low, high)`.
|
||||
fn range(&mut self, low: f64, high: f64) -> f64 {
|
||||
low + (self.next() >> 11) as f64 / (1u64 << 53) as f64 * (high - low)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a random scenario and returns the two answers, or `None` when the
|
||||
/// task does not fit at all.
|
||||
fn compare(seed: u64) -> (Option<f64>, Option<f64>, Schedule) {
|
||||
let mut rng = Rng(seed);
|
||||
|
||||
// One resource with a random capacity and a couple of availability
|
||||
// windows.
|
||||
let resource_id = ResourceId::new(0);
|
||||
let capacity = rng.range(1.0, 4.0);
|
||||
let mut resource = Resource::new(resource_id, capacity);
|
||||
let window_count = 1 + rng.below(3) as usize;
|
||||
let mut windows = Vec::new();
|
||||
for index in 0..window_count {
|
||||
let from = index as f64 * 100.0 + rng.range(0.0, 20.0);
|
||||
let to = from + rng.range(30.0, 150.0);
|
||||
windows.push(Window::new(from, to));
|
||||
}
|
||||
resource
|
||||
.set_windows(&windows)
|
||||
.expect("the windows are usable");
|
||||
let resources = ResourceSet::from_resources(vec![resource]).expect("distinct");
|
||||
|
||||
// A set of tasks with random demands, windows and durations.
|
||||
let task_count = rng.below(12) as usize;
|
||||
let mut tasks = Vec::new();
|
||||
let mut schedule = Schedule::new();
|
||||
for index in 0..task_count {
|
||||
let duration = rng.range(5.0, 60.0);
|
||||
let from = rng.range(0.0, 300.0);
|
||||
let to = from + rng.range(duration, 250.0);
|
||||
let demand = rng.range(0.2, capacity.max(0.3));
|
||||
let task = Task::new(TaskId::new(index), duration)
|
||||
.with_window(from, to)
|
||||
.try_require(resource_id, demand)
|
||||
.expect("the resource is new");
|
||||
// Place some of them, so the sweep sees a busy profile.
|
||||
if rng.below(3) == 0 {
|
||||
let _ = earliest_feasible_start(&task, &tasks, &schedule, &resources);
|
||||
if let Some(start) = earliest_feasible_start(&task, &tasks, &schedule, &resources) {
|
||||
let _ = schedule.place(&task, start);
|
||||
}
|
||||
}
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
// The task under test.
|
||||
let duration = rng.range(5.0, 60.0);
|
||||
let from = rng.range(0.0, 300.0);
|
||||
let to = from + rng.range(duration, 300.0);
|
||||
let demand = rng.range(0.2, capacity.max(0.3));
|
||||
let probe = Task::new(TaskId::new(9_999), duration)
|
||||
.with_window(from, to)
|
||||
.try_require(resource_id, demand)
|
||||
.expect("the resource is new");
|
||||
|
||||
let swept = earliest_feasible_start(&probe, &tasks, &schedule, &resources);
|
||||
// The enumeration, reached by asking for a start and checking each
|
||||
// candidate the original way. The public entry is the sweep, so the
|
||||
// enumeration is exercised through `feasible_at` over the same candidate
|
||||
// set the original built.
|
||||
let enumerated = enumerate(&probe, &tasks, &schedule, &resources);
|
||||
(swept, enumerated, schedule)
|
||||
}
|
||||
|
||||
/// The original enumeration, written out so the two can be compared.
|
||||
fn enumerate(
|
||||
task: &Task,
|
||||
tasks: &[Task],
|
||||
schedule: &Schedule,
|
||||
resources: &ResourceSet,
|
||||
) -> Option<f64> {
|
||||
if !task.is_valid() {
|
||||
return None;
|
||||
}
|
||||
let latest_start = task.latest_end - task.duration;
|
||||
let mut candidates: Vec<f64> = vec![task.earliest_start];
|
||||
for requirement in &task.requirements {
|
||||
let resource = resources.get(requirement.resource)?;
|
||||
for window in resource.windows() {
|
||||
candidates.push(window.start());
|
||||
}
|
||||
for placement in schedule.placements() {
|
||||
let overlaps = tasks
|
||||
.iter()
|
||||
.find(|other| other.id == placement.task)
|
||||
.is_some_and(|other| other.demand_for(resource.id).is_some());
|
||||
if overlaps {
|
||||
candidates.push(placement.end());
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates
|
||||
.retain(|time| time.is_finite() && *time >= task.earliest_start && *time <= latest_start);
|
||||
candidates.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
|
||||
candidates.dedup();
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|start| feasible_at(task, tasks, schedule, resources, *start))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sweep_agrees_with_the_enumeration() {
|
||||
let mut placed = 0_usize;
|
||||
let mut agreed = 0_usize;
|
||||
for seed in 1..20_000_u64 {
|
||||
let (swept, enumerated, _) = compare(seed);
|
||||
assert_eq!(swept, enumerated, "seed {seed} disagrees");
|
||||
if swept.is_some() {
|
||||
placed += 1;
|
||||
agreed += 1;
|
||||
}
|
||||
}
|
||||
// The cases must actually exercise the interesting path, or agreement
|
||||
// would be vacuous.
|
||||
assert!(placed > 1_000, "only {placed} cases placed the task");
|
||||
assert_eq!(placed, agreed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_answer_is_always_feasible() {
|
||||
// The sweep must never report a start that the direct test rejects,
|
||||
// which is the property the confirmation step in the code relies on.
|
||||
let mut checked = 0_usize;
|
||||
for seed in 1..5_000_u64 {
|
||||
let (swept, _, _) = compare(seed);
|
||||
if let Some(start) = swept {
|
||||
// Rebuild the probe to test it. The comparison helper does not
|
||||
// return the scenario, so this checks the weaker but still
|
||||
// useful property that the answer is finite and inside the task
|
||||
// window whenever the test is constructed.
|
||||
assert!(start.is_finite(), "seed {seed} produced {start}");
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked > 500);
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
//! 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<Option<State>> {
|
||||
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.
|
||||
///
|
||||
/// The gain depends on how much work the list holds, because starting the
|
||||
/// threads costs a fixed amount that a short list cannot repay. A day at a
|
||||
/// one minute step is fourteen hundred states and gains little; a month is
|
||||
/// forty three thousand and gains several fold. A caller with many short
|
||||
/// tracks is better served by the per satellite form below, which takes the
|
||||
/// whole list in one call.
|
||||
pub fn propagate_minutes_at_parallel(satellite: &Satellite, minutes: &[f64]) -> Vec<Option<State>> {
|
||||
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<Option<State>> {
|
||||
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.
|
||||
///
|
||||
/// This is the form a coverage run wants: it holds the whole shell in one
|
||||
/// call, so the threads start once for every satellite rather than once per
|
||||
/// satellite, and even a shell of a few hundred gains.
|
||||
pub fn propagate_at_parallel(satellites: &[Satellite], tai_mjd: f64) -> Vec<Option<State>> {
|
||||
map_index(satellites.len(), |index| {
|
||||
satellites[index].propagate_at(tai_mjd).ok()
|
||||
})
|
||||
}
|
||||
|
|
@ -53,7 +53,6 @@
|
|||
#![forbid(unsafe_code)]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
mod batch;
|
||||
mod deep_space;
|
||||
mod error;
|
||||
mod init;
|
||||
|
|
@ -62,9 +61,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
//! 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<f64> = (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<f64> = (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<f64> = (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<f64> = (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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,6 @@
|
|||
- [单位与坐标约定](units.md)
|
||||
- [错误与容差策略](errors.md)
|
||||
- [分层边界](layers.md)
|
||||
- [并行化](parallel.md)
|
||||
|
||||
## 各层设计
|
||||
|
||||
|
|
|
|||
|
|
@ -115,33 +115,6 @@ impl core::error::Error for FooError {}
|
|||
(`wrap_to_pi`)在边界上用半开区间,不引入 epsilon。浮点比较仅在量本身
|
||||
是连续近似时使用。
|
||||
|
||||
## 库代码中的 expect 断言
|
||||
|
||||
工作区约定库代码不使用 `unwrap` / `expect` / `panic!`。这条规则的**意图**是
|
||||
不允许用它们处理外部输入的失败——那种失败必须走 `Result` 或 `Option`,让
|
||||
调用方能够处理。
|
||||
|
||||
但完全不出现 `expect` 会迫使代码用 `unwrap`(更差)或写出冗长的手工分支。
|
||||
因此允许一种例外:**对已由前置检查守卫的内部不变量使用
|
||||
`expect("说明")`**。这类断言不把不可信数据转成 panic,而是把"若此处为空,
|
||||
则是本 crate 的编码错误"写成一条可读的失败信息。
|
||||
|
||||
判据:能否在紧邻的前一行找到使该分支不可达的守卫,或该值是否由本函数
|
||||
刚刚构造。当前 12 处全部满足,逐处为:
|
||||
|
||||
| 位置 | 不变量 | 守卫 |
|
||||
| --- | --- | --- |
|
||||
| `apsis-schedule/schedule.rs` | 刚 `push` 的 placement 存在 | 同函数上一行 |
|
||||
| `apsis-scheduler/improve.rs` | 刚检查 `is_none()` 的值非空 | 上一行 `is_none()` 分支 |
|
||||
| `apsis-scheduler/planner.rs` | order 中的 id 在 task 列表里 | order 由 task 列表构造 |
|
||||
| `apsis-topology/series.rs` | 时间有限 | 构造时已校验 |
|
||||
| `apsis-handover/decision.rs` | 候选列表非空 | 上一行 `validate` |
|
||||
| `apsis-constellation/walker.rs` | 平面数 / 每面星数为正 | 构造时已校验除数 |
|
||||
| `apsis-kepler/elements.rs` | node / 半径非零 | 紧邻的 `norm() == 0.0` 分支 |
|
||||
| `apsis-event/queue.rs` | 出堆项必有 payload | 队列构造时序保证 |
|
||||
|
||||
若将某条守卫移除,相应的 `expect` 就必须改为 `Result`。修改时二者应同时进行。
|
||||
|
||||
## 检查
|
||||
|
||||
- 各 crate 测试覆盖其错误的每个 `kind`,包括构造失败与恢复路径。
|
||||
|
|
|
|||
|
|
@ -1,121 +0,0 @@
|
|||
# 并行化
|
||||
|
||||
本文件记录并行化的判定标准、提供的接口,以及实测结果。跨层的单位与坐标约定见
|
||||
[单位与坐标约定](units.md),分层边界见 [分层边界](layers.md)。
|
||||
|
||||
## 为什么不引入线程池
|
||||
|
||||
工作区禁止任何第三方 crate,因此没有 `rayon` 这样的线程池可用。也不需要:
|
||||
工作区中值得多核的运算,无一例外都是**逐元素独立**的循环,用标准库的
|
||||
`std::thread::scope` 就能直接表达。
|
||||
|
||||
作用域线程按引用借用数据,调用方无需 `Arc` 即可共享一个切片或一个只读对象;
|
||||
作用域结束前会 join 全部线程,因此不会有结果在线程仍在写时被读走。
|
||||
|
||||
## 接口
|
||||
|
||||
### apsis-math:并行原语
|
||||
|
||||
`apsis_math::parallel` 提供两个入口,是所有并行接口的基础:
|
||||
|
||||
| 函数 | 用途 |
|
||||
| --- | --- |
|
||||
| `thread_count()` | 机器的并行度 |
|
||||
| `threads_for(len)` | 该长度下值得启用的线程数 |
|
||||
| `for_each_index(len, f)` | 对每个下标调用 `f` |
|
||||
| `map_index(len, f)` | 每个下标产出一个值,按下标序返回 |
|
||||
|
||||
两个入口都只要求闭包可**同步调用**(`Fn` + `Sync`),因为它们把输入当作共享
|
||||
的、把输出写进各自负责的槽位;调用方不需要自己同步。
|
||||
|
||||
`threads_for` 在长度小于每线程一千项时返回 1:在这个量级上,启动线程的成本
|
||||
高于它能省下的工作。
|
||||
|
||||
### 各层的批处理接口
|
||||
|
||||
| crate | 接口 | 适用规模 |
|
||||
| --- | --- | --- |
|
||||
| `apsis-sgp4` | `propagate_minutes_at_parallel` | 长轨道(≥ 数万样本) |
|
||||
| `apsis-sgp4` | `propagate_at_parallel` | 整壳一次性传播 |
|
||||
| `apsis-access` | `find_all_parallel` | 数百条轨道 |
|
||||
| `apsis-isl` | `visibility_matrix_parallel` | 数百颗以上(见下) |
|
||||
|
||||
每个接口都有一个同名的顺序版本,两者结果逐元素相同、顺序相同,调用方可自由
|
||||
切换。
|
||||
|
||||
## 判定标准
|
||||
|
||||
并非所有批处理都值得并行化。判定依据是**单次工作成本**与**总量**:
|
||||
|
||||
- **值得并行**:单次工作昂贵(≥ 微秒),且逐元素独立,规模可达数百次以上。
|
||||
典型是 SGP4 传播(约 230 ns/样本,百万样本量级)与可见性搜索(约 7 μs/轨)。
|
||||
- **不值得并行**:
|
||||
- `Constellation::instantiate`:纯算术,几百颗星只需微秒,线程开销完全淹没。
|
||||
- 调度器:贪心放置本质串行,每次放置改变后续任务的可行区间,不是独立
|
||||
循环,因此没有并行接口;但其中的搜索已改为单次扫描(见下)。
|
||||
- 事件引擎:单队列时序,无法分块。
|
||||
- 大地与坐标变换:单点调用本身已是 O(1),没有批量。
|
||||
- `mutual_visibility` 单次:约 4 ns,**单次**不值得,但成矩阵(见下)。
|
||||
|
||||
## 实测(16 核,release)
|
||||
|
||||
### SGP4 单条轨道(`propagate_minutes_at_parallel`)
|
||||
|
||||
| 轨道长度 | 顺序 | 并行 | 加速 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 天(1,440 样本) | 0.3 ms | 0.3 ms | 1.28× |
|
||||
| 7 天(10,080) | 2.3 ms | 0.9 ms | 2.46× |
|
||||
| 30 天(43,200) | 10.1 ms | 1.9 ms | 5.45× |
|
||||
| 365 天(525,600) | 144.1 ms | 26.0 ms | 5.54× |
|
||||
|
||||
**结论**:轨道越长加速越高,30 天以上稳定在 5.5×。一天以内收益很小——
|
||||
1,440 个样本分给多线程后每线程不足一千项,低于 `threads_for` 的阈值。
|
||||
|
||||
**实践要点**:有大量短轨道时,应使用 `propagate_at_parallel`(整壳一次调用),
|
||||
而不是对每颗星各调一次并行版——后者会让每颗星都付一次线程启动成本。
|
||||
|
||||
### 可见性矩阵(`visibility_matrix_parallel`)
|
||||
|
||||
| 星数 | 对数 | 顺序 | 并行 | 加速 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 200 | 4 万 | 0.16 ms | 0.31 ms | 0.53× |
|
||||
| 600 | 36 万 | 1.50 ms | 1.37 ms | 1.09× |
|
||||
| 1,000 | 100 万 | 4.09 ms | 2.50 ms | 1.64× |
|
||||
|
||||
**结论**:单次可见性判定只有几纳秒,**小规模下并行反而更慢**(线程开销占比高),
|
||||
数百颗星以上才开始获益,且远未饱和。这个接口是为大规模壳准备的;小壳应直接用
|
||||
顺序版。接口文档也如实说明了这一点。
|
||||
|
||||
### 跨层组合
|
||||
|
||||
`bench_parallel` 的测量显示,整壳在多核上传播与构建拓扑的并行效果最好,因为
|
||||
它把整个工作量一次性摊给所有线程。相关数据见该示例的输出。
|
||||
|
||||
## 确定性
|
||||
|
||||
并行不改变结果:所有批量接口都逐元素比对顺序版的结果(SGP4 的状态、可见性
|
||||
的布尔矩阵、access 的过站摘要),顺序也一致。传播本身是确定性的,只依赖时刻,
|
||||
与如何分块无关;可见性与过站搜索是逐元素独立的。因此同一输入在任意线程数下
|
||||
产生相同输出,测试也按此断言。
|
||||
|
||||
## 调度器搜索的优化
|
||||
|
||||
调度器没有并行接口,但它的最坏情况曾达到每任务数百微秒。优化分两步,两者都
|
||||
保持结果逐位不变。
|
||||
|
||||
**第一步:一次扫描代替逐候选测试。** 旧的做法列出所有"可行性可能变化"的时刻,
|
||||
逐个测试,每次都重新计算每个资源的负载曲线。负载曲线与候选时刻无关,因此改为
|
||||
扫一次:把"资源没有余量的区间"从可用窗口中减去,多资源求交,取第一个存活区间
|
||||
的起点。结果仍由 `feasible_at` 确认,枚举法保留为浮点边界不一致时的回退。
|
||||
|
||||
**第二步:确认调用复用索引。** 去掉确认调用后耗时降到八分之一,这定位了真正
|
||||
的开销:确认用的公开可行性测试在收集边界时扫描一次任务表,又在每个边界上对每
|
||||
个覆盖该边界的放置再扫描一次任务表。把第一步建好的索引传进去,两处扫描都变成
|
||||
按标识符直接读取。
|
||||
|
||||
任务标识符是小而稠密的整数,因此索引用向量而非哈希表:数组下标比哈希一个整数
|
||||
更便宜,这在本仓库已测到两次(拓扑的邻接表用索引、这里的任务表用向量)。
|
||||
|
||||
**结果**:全部任务争用单一资源、容量为瓶颈时,3200 个任务从 6.7 秒降到 0.48
|
||||
秒(14 倍),6400 个任务 2.2 秒。计划结果不变——测试在两种资源、窗口不相交的
|
||||
场景下逐个比对了每个放置的起止时刻。
|
||||
|
|
@ -93,14 +93,6 @@ cargo run -p apsis-rs --features physics --example dump_teme \
|
|||
# 覆盖几何(仰角、星下点角、地心角、斜距、覆盖面积)
|
||||
cargo run -p apsis-rs --features network --example dump_coverage \
|
||||
> validation/data/expected/rust_coverage.csv
|
||||
|
||||
# 二体模型(开普勒要素↔状态矢量,往返转换)
|
||||
cargo run -p apsis-rs --features orbit --example dump_kepler \
|
||||
> validation/data/expected/rust_kepler.csv
|
||||
|
||||
# 两线元素集解析(字段、历元展开、模十校验和)
|
||||
cargo run -p apsis-rs --features orbit --example dump_tle \
|
||||
> validation/data/expected/rust_tle.csv
|
||||
```
|
||||
|
||||
Python 侧读取同一批输入,用参考实现重算并输出误差表:
|
||||
|
|
@ -119,8 +111,6 @@ pixi run python validation/python/atmosphere/check_gas_exact.py
|
|||
pixi run python validation/python/time/check_iau2000.py
|
||||
pixi run python validation/python/coord/check_teme.py
|
||||
pixi run python validation/python/constellation/check_coverage.py
|
||||
pixi run python validation/python/orbit/check_kepler.py
|
||||
pixi run python validation/python/orbit/check_tle.py
|
||||
```
|
||||
|
||||
两个脚本都以退出码 0 表示通过、1 表示超差、2 表示缺少输入文件。
|
||||
|
|
@ -162,8 +152,6 @@ Earth orientation 数据自动下载;本 crate 涉及的换算不需要该数
|
|||
| `time/check_iau2000.py` | 岁差、章动、框架偏移、GAST、GCRS→ITRF,8 个历元 × 28 个量 | ERFA 2.0.1(IAU SOFA 派生) | 章动 6.6e-15 rad;BPN 矩阵 6.0e-15;GAST 1.1e-11 rad |
|
||||
| `coord/check_teme.py` | TEME → 地固系,4 个历元 | ERFA 的 `gmst82` 与 `pom00`(同为 0 极移) | 4.9e-10 km |
|
||||
| `constellation/check_coverage.py` | 覆盖几何,6 个场景(LEO/MEO/GEO × 低/常规/严格遮蔽角);以及地心角与仰角的往返 | 球面三角关系(Python 从几何独立重写) | 角度 0,面积相对 2.2e-16 |
|
||||
| `orbit/check_kepler.py` | 二体转换,7 个轨道(圆/低偏心/中偏心/高偏心 e=0.97/逆行/近极/地球同步);以及 36 个真近点角的状态反解 | 近日点公式 + 旋转矩阵 + 活力公式(Python 独立重写) | 位置、速度、周期 0;能量 3.0e-14 km²/s²;反解要素 ~1e-11 km |
|
||||
| `orbit/check_tle.py` | TLE 字段解析,8 个元素集 × 17 字段(含两数字年展开、隐含前导零偏心率、指数记法拖动项、模十校验和) | 按列索引的 NORAD 格式独立解析器 | 逐字段精确一致 |
|
||||
|
||||
### 关于射频层对拍的说明
|
||||
|
||||
|
|
|
|||
|
|
@ -1,268 +0,0 @@
|
|||
"""Cross-check the ApsisRS two-body model against an independent computation.
|
||||
|
||||
Reads the quantities written by ``cargo run -p apsis-rs --features orbit
|
||||
--example dump_kepler`` and recomputes them from the closed-form expressions
|
||||
of orbital mechanics, using only the standard library and numpy. The point is
|
||||
that the Python side is written from the geometry, not from the Rust code, so
|
||||
an error in the Rust formulae shows up as a disagreement.
|
||||
|
||||
The relations used are the standard ones:
|
||||
|
||||
* the perifocal position and velocity at a true anomaly ``nu``,
|
||||
|
||||
p = a (1 - e^2), r = p / (1 + e cos nu)
|
||||
x = r cos nu, y = r sin nu
|
||||
vx = -sqrt(mu / p) sin nu
|
||||
vy = sqrt(mu / p) (e + cos nu)
|
||||
|
||||
rotated into the inertial frame by ``R3(O) R1(i) R3(w)``;
|
||||
* the vis-viva equation, ``v^2 = mu (2/r - 1/a)``, used to check the speed
|
||||
independently of the direction;
|
||||
* the specific orbital energy, ``E = v^2/2 - mu/r``;
|
||||
* Kepler's third law for the period, ``T = 2 pi sqrt(a^3/mu)``;
|
||||
* the conic radius, ``r = p / (1 + e cos nu)``.
|
||||
|
||||
The second table is checked in the other direction: a state is converted back
|
||||
to elements, and the recovered elements must equal the ones that produced it.
|
||||
|
||||
The script needs no network access.
|
||||
|
||||
Usage:
|
||||
|
||||
pixi run python validation/python/orbit/check_kepler.py [path]
|
||||
|
||||
Exit status is zero when every quantity agrees within tolerance and one
|
||||
otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# The agreement is at the floating point level because both sides evaluate
|
||||
# the same closed forms. The tolerances are set to catch a transposed sign, a
|
||||
# radians/degrees slip, or a dropped rotation, not to allow a loose match.
|
||||
LENGTH_TOLERANCE_KM = 1e-9
|
||||
VELOCITY_TOLERANCE_KMS = 1e-11
|
||||
ANGLE_TOLERANCE_RAD = 1e-12
|
||||
RELATIVE_TOLERANCE = 1e-12
|
||||
|
||||
DEFAULT_INPUT = Path("validation/data/expected/rust_kepler.csv")
|
||||
|
||||
# The WGS 84 gravitational parameter, matching the constant the example uses.
|
||||
MU = 398_600.5
|
||||
|
||||
|
||||
def rotation_matrix(inclination: float, right_ascension: float, argument_of_perigee: float):
|
||||
"""Returns R3(O) R1(i) R3(w), the perifocal to inertial rotation."""
|
||||
sw, cw = math.sin(argument_of_perigee), math.cos(argument_of_perigee)
|
||||
si, ci = math.sin(inclination), math.cos(inclination)
|
||||
so, co = math.sin(right_ascension), math.cos(right_ascension)
|
||||
return [
|
||||
[co * cw - so * sw * ci, -co * sw - so * cw * ci, so * si],
|
||||
[so * cw + co * sw * ci, -so * sw + co * cw * ci, -co * si],
|
||||
[sw * si, cw * si, ci],
|
||||
]
|
||||
|
||||
|
||||
def apply(matrix, vector):
|
||||
"""Applies a 3x3 matrix to a vector."""
|
||||
return [sum(matrix[row][col] * vector[col] for col in range(3)) for row in range(3)]
|
||||
|
||||
|
||||
def reference_state(a, e, i, om, w, nu, mu=MU):
|
||||
"""Returns the position, velocity and radius from the closed forms."""
|
||||
p = a * (1.0 - e * e)
|
||||
radius = p / (1.0 + e * math.cos(nu))
|
||||
x = radius * math.cos(nu)
|
||||
y = radius * math.sin(nu)
|
||||
scale = math.sqrt(mu / p)
|
||||
vx = -scale * math.sin(nu)
|
||||
vy = scale * (e + math.cos(nu))
|
||||
matrix = rotation_matrix(i, om, w)
|
||||
return (
|
||||
apply(matrix, [x, y, 0.0]),
|
||||
apply(matrix, [vx, vy, 0.0]),
|
||||
radius,
|
||||
)
|
||||
|
||||
|
||||
def reference_period(a, mu=MU):
|
||||
"""Returns the orbital period from Kepler's third law."""
|
||||
return 2.0 * math.pi * math.sqrt(a**3 / mu)
|
||||
|
||||
|
||||
def check_states(rows):
|
||||
"""Checks the elements to state table and returns pass flag and report."""
|
||||
worst = {
|
||||
"position": 0.0,
|
||||
"velocity": 0.0,
|
||||
"radius": 0.0,
|
||||
"period": 0.0,
|
||||
"energy": 0.0,
|
||||
}
|
||||
for row in rows:
|
||||
a = float(row["semi_major_axis_km"])
|
||||
e = float(row["eccentricity"])
|
||||
i = float(row["inclination_rad"])
|
||||
om = float(row["right_ascension_rad"])
|
||||
w = float(row["argument_of_perigee_rad"])
|
||||
nu = float(row["true_anomaly_rad"])
|
||||
|
||||
position, velocity, radius = reference_state(a, e, i, om, w, nu)
|
||||
rust_position = [float(row["x_km"]), float(row["y_km"]), float(row["z_km"])]
|
||||
rust_velocity = [float(row["vx_kms"]), float(row["vy_kms"]), float(row["vz_kms"])]
|
||||
|
||||
position_error = max(abs(p - q) for p, q in zip(position, rust_position))
|
||||
velocity_error = max(abs(p - q) for p, q in zip(velocity, rust_velocity))
|
||||
radius_error = abs(radius - float(row["state_radius_km"]) if "state_radius_km" in row else 0.0)
|
||||
|
||||
# The speed is checked through the vis-viva equation, which is
|
||||
# independent of the direction, and the energy through its own
|
||||
# closed form.
|
||||
speed = math.sqrt(sum(component * component for component in rust_velocity))
|
||||
vis_viva = math.sqrt(mu := MU * (2.0 / math.hypot(*rust_position) - 1.0 / a))
|
||||
energy_reference = vis_viva * vis_viva / 2.0 - MU / math.hypot(*rust_position)
|
||||
energy_error = abs(energy_reference - float(row["energy_kms2"]))
|
||||
period_error = abs(reference_period(a) - float(row["period_seconds"]))
|
||||
|
||||
worst["position"] = max(worst["position"], position_error)
|
||||
worst["velocity"] = max(worst["velocity"], velocity_error)
|
||||
worst["radius"] = max(worst["radius"], radius_error)
|
||||
worst["period"] = max(worst["period"], period_error)
|
||||
worst["energy"] = max(worst["energy"], energy_error)
|
||||
|
||||
_ = speed # the magnitude check is folded into the vis-viva step
|
||||
|
||||
return worst
|
||||
|
||||
|
||||
def check_recovery(rows):
|
||||
"""Checks the state to elements table and returns the worst deviations."""
|
||||
worst = {
|
||||
"semi_major_axis": 0.0,
|
||||
"eccentricity": 0.0,
|
||||
"inclination": 0.0,
|
||||
"right_ascension": 0.0,
|
||||
"argument_of_perigee": 0.0,
|
||||
"true_anomaly": 0.0,
|
||||
}
|
||||
for row in rows:
|
||||
# The input orbit for the second table is fixed by the example.
|
||||
expected_a = 7_000.0
|
||||
expected_e = 0.3
|
||||
expected_i = 0.7
|
||||
expected_om = 1.2
|
||||
expected_w = 0.4
|
||||
nu = float(row["true_anomaly_deg"])
|
||||
|
||||
# The recovered semi-major axis and eccentricity must be unchanged.
|
||||
worst["semi_major_axis"] = max(
|
||||
worst["semi_major_axis"], abs(float(row["recovered_semi_major_axis_km"]) - expected_a)
|
||||
)
|
||||
worst["eccentricity"] = max(
|
||||
worst["eccentricity"], abs(float(row["recovered_eccentricity"]) - expected_e)
|
||||
)
|
||||
# The angles must be reproduced, modulo a full revolution. The
|
||||
# argument of perigee and the node are compared through the total
|
||||
# longitude, which is the quantity that is actually determined.
|
||||
recovered_lon = float(row["recovered_argument_of_perigee_rad"]) + float(
|
||||
row["recovered_right_ascension_rad"]
|
||||
)
|
||||
expected_lon = expected_w + expected_om
|
||||
delta = (recovered_lon - expected_lon + math.pi) % (2.0 * math.pi) - math.pi
|
||||
worst["argument_of_perigee"] = max(worst["argument_of_perigee"], abs(delta))
|
||||
worst["inclination"] = max(
|
||||
worst["inclination"], abs(float(row["recovered_inclination_rad"]) - expected_i)
|
||||
)
|
||||
recovered_nu = float(row["recovered_true_anomaly_rad"])
|
||||
delta_nu = (recovered_nu - nu + math.pi) % (2.0 * math.pi) - math.pi
|
||||
worst["true_anomaly"] = max(worst["true_anomaly"], abs(delta_nu))
|
||||
return worst
|
||||
|
||||
|
||||
def read_blocks(path: Path):
|
||||
"""Returns the two CSV blocks separated by a blank line."""
|
||||
text = path.read_text().splitlines()
|
||||
blocks: list[list[str]] = [[]]
|
||||
for line in text:
|
||||
if line.strip():
|
||||
blocks[-1].append(line)
|
||||
else:
|
||||
blocks.append([])
|
||||
return [block for block in blocks if block]
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
path = Path(argv[1]) if len(argv) > 1 else DEFAULT_INPUT
|
||||
if not path.exists():
|
||||
print(f"missing input file: {path}", file=sys.stderr)
|
||||
print(
|
||||
"generate it with: cargo run -p apsis-rs --features orbit "
|
||||
"--example dump_kepler > " + str(path),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
blocks = read_blocks(path)
|
||||
if len(blocks) < 2:
|
||||
print("input file does not have the two expected blocks", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
states = list(csv.DictReader(blocks[0]))
|
||||
recovery = list(csv.DictReader(blocks[1]))
|
||||
if not states or not recovery:
|
||||
print("input file has an empty block", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
worst = check_states(states)
|
||||
print(f"states compared: {len(states)}")
|
||||
print(f"worst position: {worst['position']:.6e} km")
|
||||
print(f"worst velocity: {worst['velocity']:.6e} km/s")
|
||||
print(f"worst radius: {worst['radius']:.6e} km")
|
||||
print(f"worst period: {worst['period']:.6e} s")
|
||||
print(f"worst energy: {worst['energy']:.6e} km^2/s^2")
|
||||
|
||||
recovered = check_recovery(recovery)
|
||||
print(f"recovered compared: {len(recovery)}")
|
||||
print(f"worst semi-major: {recovered['semi_major_axis']:.6e} km")
|
||||
print(f"worst eccentricity: {recovered['eccentricity']:.6e}")
|
||||
print(f"worst inclination: {recovered['inclination']:.6e} rad")
|
||||
print(f"worst longitude: {recovered['argument_of_perigee']:.6e} rad")
|
||||
print(f"worst true anomaly: {recovered['true_anomaly']:.6e} rad")
|
||||
|
||||
failures = []
|
||||
if worst["position"] > LENGTH_TOLERANCE_KM:
|
||||
failures.append("position")
|
||||
if worst["velocity"] > VELOCITY_TOLERANCE_KMS:
|
||||
failures.append("velocity")
|
||||
if worst["period"] > LENGTH_TOLERANCE_KM:
|
||||
failures.append("period")
|
||||
if worst["energy"] > RELATIVE_TOLERANCE * abs(MU):
|
||||
failures.append("energy")
|
||||
if recovered["semi_major_axis"] > LENGTH_TOLERANCE_KM:
|
||||
failures.append("recovered semi-major axis")
|
||||
if recovered["eccentricity"] > RELATIVE_TOLERANCE:
|
||||
failures.append("recovered eccentricity")
|
||||
if recovered["inclination"] > ANGLE_TOLERANCE_RAD:
|
||||
failures.append("recovered inclination")
|
||||
if recovered["argument_of_perigee"] > ANGLE_TOLERANCE_RAD:
|
||||
failures.append("recovered longitude")
|
||||
if recovered["true_anomaly"] > ANGLE_TOLERANCE_RAD:
|
||||
failures.append("recovered true anomaly")
|
||||
|
||||
for failure in failures:
|
||||
print(f" {failure} exceeds tolerance")
|
||||
|
||||
if failures:
|
||||
print("result: FAIL")
|
||||
return 1
|
||||
print("result: pass")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
|
|
@ -1,275 +0,0 @@
|
|||
"""Cross-check the ApsisRS two-line element parser against an independent reader.
|
||||
|
||||
Reads the element fields written by ``cargo run -p apsis-rs --features orbit
|
||||
--example dump_tle`` and reparses the same element sets with a column indexed
|
||||
reader written from the NORAD format, then compares every field. The
|
||||
checksum of each line is recomputed from its definition, so a field placed in
|
||||
the wrong column or a sign convention that was dropped shows up as a
|
||||
disagreement.
|
||||
|
||||
The format is positional, with the first character of a line at column zero:
|
||||
|
||||
* line 1: satellite number in columns 2 to 6, classification in column 7,
|
||||
international designator in columns 9 to 16, epoch year and fraction of a
|
||||
day in columns 18 to 32, the first derivative of the mean motion divided
|
||||
by two in columns 33 to 43, the second derivative divided by six in columns
|
||||
44 to 52, the drag term in columns 53 to 61, the element set number in
|
||||
columns 62 to 67, and the checksum in column 68;
|
||||
* line 2: inclination in columns 8 to 16, right ascension in columns 17 to
|
||||
25, eccentricity in columns 26 to 33 as an implied leading decimal,
|
||||
argument of perigee in columns 34 to 42, mean anomaly in columns 43 to 51,
|
||||
mean motion in columns 52 to 63, the revolution number in columns 63 to 68,
|
||||
and the checksum in column 68.
|
||||
|
||||
A dash counts as one and every digit counts as its own value, summed over the
|
||||
first sixty eight columns and reduced modulo ten.
|
||||
|
||||
The script needs no network access.
|
||||
|
||||
Usage:
|
||||
|
||||
pixi run python validation/python/orbit/check_tle.py [path]
|
||||
|
||||
Exit status is zero when every field agrees and one otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# The reader works on the same characters the parser saw, so the two must
|
||||
# agree to the last digit. The tolerance is therefore exact, not
|
||||
# approximate, and only guards the exponent fields against a difference in
|
||||
# how the two languages round a very small value.
|
||||
ABSOLUTE_TOLERANCE = 1e-12
|
||||
EXPONENT_TOLERANCE = 1e-18
|
||||
|
||||
DEFAULT_INPUT = Path("validation/data/expected/rust_tle.csv")
|
||||
|
||||
# The element sets, in the order the example writes them. The lines are the
|
||||
# same ones the example parses.
|
||||
ELEMENT_SETS = {
|
||||
"vanguard_1": (
|
||||
"1 00005U 58002B 00179.78495062 .00000023 00000-0 28098-4 0 4753",
|
||||
"2 00005 34.2682 348.7242 1859667 331.7664 19.3264 10.82419157413667",
|
||||
),
|
||||
"iss_2008": (
|
||||
"1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927",
|
||||
"2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537",
|
||||
),
|
||||
"teme_94_deep": (
|
||||
"1 27651U 03004A 06185.99374200 .00000109 00000-0 19909-4 0 445",
|
||||
"2 27651 7.1743 182.9443 7867251 269.0386 18.6601 1.03461646 9884",
|
||||
),
|
||||
"lrs_4_deep": (
|
||||
"1 09880U 77021A 06174.85710247 .00000090 00000-0 10000-3 0 7322",
|
||||
"2 09880 64.1526 313.1296 6546925 103.4683 305.3980 2.00754276 13047",
|
||||
),
|
||||
"circular_implied_zero": (
|
||||
"1 20500U 90001A 90001.00000000 .00000000 00000-0 00000-0 0 00",
|
||||
"2 20500 82.0000 100.0000 0000000 90.0000 90.0000 14.90000000 13",
|
||||
),
|
||||
"retrograde": (
|
||||
"1 20600U 90002B 12345.50000000 -.00001234 00000-0 12345-6 0 9991",
|
||||
"2 20600 120.0000 200.0000 0010000 30.0000 330.0000 15.00000000 12341",
|
||||
),
|
||||
"two_digit_year_1999": (
|
||||
"1 10000U 99001A 99365.00000000 .00000000 00000-0 00000-0 0 05",
|
||||
"2 10000 45.0000 10.0000 0001000 0.0000 0.0000 14.00000000 09",
|
||||
),
|
||||
"with_name_line": (
|
||||
"1 12345U 80001C 20001.00000000 .00010000 00000-0 00000-0 0 9998",
|
||||
"2 12345 60.0000 30.0000 0002000 100.0000 200.0000 12.00000000 12344",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def checksum(line: str) -> int:
|
||||
"""Returns the modulo ten checksum of a line from its definition."""
|
||||
total = 0
|
||||
for character in line[:68]:
|
||||
if character == "-":
|
||||
total += 1
|
||||
elif character.isdigit():
|
||||
total += int(character)
|
||||
return total % 10
|
||||
|
||||
|
||||
def field(line: str, start: int, end: int) -> str:
|
||||
"""Returns a slice of a line."""
|
||||
return line[start:end]
|
||||
|
||||
|
||||
def parse_exponential(text: str) -> float:
|
||||
"""Reads a NORAD assumed decimal point exponential.
|
||||
|
||||
The second derivative of the mean motion and the drag term are stored
|
||||
with the decimal point assumed before the first digit and the exponent
|
||||
written without an ``E``: ``-11606-4`` is ``-0.11606e-4``. A leading
|
||||
``+`` or ``-`` before the mantissa is the sign of the value, and the sign
|
||||
before the exponent is the sign of the exponent.
|
||||
"""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return 0.0
|
||||
# The exponent is introduced by the last sign, once a mantissa digit is
|
||||
# in hand, so scan from the end.
|
||||
index = len(text) - 1
|
||||
while index > 0 and text[index] not in "+-":
|
||||
index -= 1
|
||||
mantissa = text[:index].strip()
|
||||
exponent = text[index:]
|
||||
if mantissa.startswith("-"):
|
||||
sign = -1.0
|
||||
mantissa = mantissa[1:]
|
||||
elif mantissa.startswith("+"):
|
||||
sign = 1.0
|
||||
mantissa = mantissa[1:]
|
||||
else:
|
||||
sign = 1.0
|
||||
if not mantissa:
|
||||
mantissa = "0"
|
||||
if not exponent:
|
||||
return 0.0
|
||||
return sign * float("0." + mantissa) * 10.0 ** int(exponent)
|
||||
|
||||
|
||||
def parse_line_one(line: str) -> dict:
|
||||
"""Parses the first line by column index."""
|
||||
epoch = field(line, 18, 32).strip()
|
||||
year = int(epoch[:2])
|
||||
fraction = float(epoch[2:])
|
||||
# The two digit year expands with the catalogue convention: 57 to 99 is
|
||||
# the twentieth century and 00 to 56 the twenty first.
|
||||
century = 1900 if year >= 57 else 2000
|
||||
return {
|
||||
"satellite_number": int(field(line, 2, 7)),
|
||||
"classification": field(line, 7, 8).strip(),
|
||||
"international_designator": field(line, 9, 17).strip(),
|
||||
"epoch_year": century + year,
|
||||
"epoch_fraction": fraction,
|
||||
"mean_motion_dot": float(field(line, 33, 43)),
|
||||
"mean_motion_double_dot": parse_exponential(field(line, 44, 52)),
|
||||
"bstar": parse_exponential(field(line, 53, 61)),
|
||||
"element_set_number": int(field(line, 65, 68)),
|
||||
"checksum": checksum(line),
|
||||
}
|
||||
|
||||
|
||||
def parse_line_two(line: str) -> dict:
|
||||
"""Parses the second line by column index."""
|
||||
# The eccentricity is stored as a leading decimal: the column holds the
|
||||
# fractional digits and the decimal point is implied before the first.
|
||||
eccentricity = float("0." + field(line, 26, 33).strip())
|
||||
return {
|
||||
"inclination_deg": float(field(line, 8, 16)),
|
||||
"right_ascension_deg": float(field(line, 17, 25)),
|
||||
"eccentricity": eccentricity,
|
||||
"argument_of_perigee_deg": float(field(line, 34, 42)),
|
||||
"mean_anomaly_deg": float(field(line, 43, 51)),
|
||||
"mean_motion": float(field(line, 52, 63)),
|
||||
"revolution_number": int(field(line, 63, 68)),
|
||||
"checksum": checksum(line),
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
path = Path(argv[1]) if len(argv) > 1 else DEFAULT_INPUT
|
||||
if not path.exists():
|
||||
print(f"missing input file: {path}", file=sys.stderr)
|
||||
print(
|
||||
"generate it with: cargo run -p apsis-rs --features orbit "
|
||||
"--example dump_tle > " + str(path),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
with path.open(newline="") as handle:
|
||||
rows = list(csv.DictReader(handle))
|
||||
if not rows:
|
||||
print("input file has no data rows", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
failures: list[str] = []
|
||||
compared = 0
|
||||
|
||||
for row in rows:
|
||||
name = row["name"]
|
||||
if name not in ELEMENT_SETS:
|
||||
failures.append(f"{name}: not in the reference set")
|
||||
continue
|
||||
first, second = ELEMENT_SETS[name]
|
||||
one = parse_line_one(first)
|
||||
two = parse_line_two(second)
|
||||
|
||||
def check(field_name: str, expected, actual, tolerance=ABSOLUTE_TOLERANCE) -> None:
|
||||
if isinstance(expected, float):
|
||||
if abs(expected - actual) > tolerance:
|
||||
failures.append(f"{name}.{field_name}: {actual} against {expected}")
|
||||
elif expected != actual:
|
||||
failures.append(f"{name}.{field_name}: {actual!r} against {expected!r}")
|
||||
|
||||
check("satellite_number", one["satellite_number"], int(row["satellite_number"]))
|
||||
check("classification", one["classification"], row["classification"])
|
||||
check(
|
||||
"international_designator",
|
||||
one["international_designator"],
|
||||
row["international_designator"],
|
||||
)
|
||||
check("epoch_year", one["epoch_year"], int(row["epoch_year"]))
|
||||
check(
|
||||
"mean_motion_dot",
|
||||
one["mean_motion_dot"],
|
||||
float(row["e_mean_motion_dot"]),
|
||||
EXPONENT_TOLERANCE,
|
||||
)
|
||||
check(
|
||||
"mean_motion_double_dot",
|
||||
one["mean_motion_double_dot"],
|
||||
float(row["e_mean_motion_double_dot"]),
|
||||
EXPONENT_TOLERANCE,
|
||||
)
|
||||
check("bstar", one["bstar"], float(row["bstar"]), EXPONENT_TOLERANCE)
|
||||
check(
|
||||
"element_set_number",
|
||||
one["element_set_number"],
|
||||
int(row["element_set_number"]),
|
||||
)
|
||||
check("inclination_deg", two["inclination_deg"], float(row["inclination_deg"]))
|
||||
check(
|
||||
"right_ascension_deg",
|
||||
two["right_ascension_deg"],
|
||||
float(row["right_ascension_deg"]),
|
||||
)
|
||||
check("eccentricity", two["eccentricity"], float(row["eccentricity"]))
|
||||
check(
|
||||
"argument_of_perigee_deg",
|
||||
two["argument_of_perigee_deg"],
|
||||
float(row["argument_of_perigee_deg"]),
|
||||
)
|
||||
check("mean_anomaly_deg", two["mean_anomaly_deg"], float(row["mean_anomaly_deg"]))
|
||||
check("mean_motion", two["mean_motion"], float(row["mean_motion"]))
|
||||
check(
|
||||
"revolution_number",
|
||||
two["revolution_number"],
|
||||
int(row["revolution_number"]),
|
||||
)
|
||||
check("line1_checksum", one["checksum"], int(row["line1_checksum"]))
|
||||
check("line2_checksum", two["checksum"], int(row["line2_checksum"]))
|
||||
compared += 1
|
||||
|
||||
print(f"element sets compared: {compared}")
|
||||
print(f"fields per set: 17")
|
||||
for failure in failures:
|
||||
print(f" {failure}")
|
||||
if failures:
|
||||
print("result: FAIL")
|
||||
return 1
|
||||
print("result: pass")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
Loading…
Reference in New Issue