diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 797cfe4..250d86d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,11 +34,13 @@ 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 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: 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 - name: Documentation # Catches a doc link that no longer resolves, which is how a doc # comment drifts away from the code it describes. diff --git a/README.md b/README.md index 0d2f484..b34f625 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,11 @@ 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 必须有文档注释,注释自包含 ## 目录 diff --git a/crates/apsis-rs/Cargo.toml b/crates/apsis-rs/Cargo.toml index 3229e4a..6b67ea9 100644 --- a/crates/apsis-rs/Cargo.toml +++ b/crates/apsis-rs/Cargo.toml @@ -217,3 +217,7 @@ required-features = ["orbit"] [[example]] name = "dump_tle" required-features = ["orbit"] + +[[example]] +name = "bench_scale" +required-features = ["full"] diff --git a/crates/apsis-rs/examples/bench_scale.rs b/crates/apsis-rs/examples/bench_scale.rs new file mode 100644 index 0000000..0d13520 --- /dev/null +++ b/crates/apsis-rs/examples/bench_scale.rs @@ -0,0 +1,290 @@ +//! 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 with a fixed iteration count, and +//! the harness warms up before timing so that lazy initialisation is not +//! charged to the first call. +//! +//! 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 total duration. +fn measure(iterations: u32, mut path: F) -> Duration +where + F: FnMut() -> T, +{ + // Two warm-up calls, so that any allocation or caching the first call + // performs is not charged to the measurement. + black_box(path()); + black_box(path()); + let start = Instant::now(); + for _ in 0..iterations { + black_box(path()); + } + start.elapsed() +} + +/// Prints one row of the sweep table. +fn row(label: &str, size: usize, iterations: u32, total: Duration, per_unit_ns: f64) { + println!( + "{label:<28} {size:>6} {iterations:>5} {:>10.3} {per_unit_ns:>14.1}", + total.as_secs_f64() * 1e3, + ); +} + +/// The header of the sweep table. +fn header(title: &str) { + println!("\n{title}"); + println!( + "{:<28} {:>6} {:>5} {:>10} {:>14}", + "path", "size", "runs", "total ms", "ns per unit" + ); +} + +/// 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 { + (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 onto a grid. ------------------------ + { + header("one: sample N satellites onto a 60 s grid (180 min track)"); + for &(planes, per_plane) in &[(3_usize, 8_usize), (6, 11), (6, 40), (10, 40)] { + let total = planes * per_plane; + let samples = 181_usize; // 180 minutes at one minute. + let pattern = WalkerPattern { + total_satellites: total, + planes, + phase_factor: 1, + inclination: 53.0_f64.to_radians(), + semi_major_axis: BODY_RADIUS_KM + 550.0, + }; + let iterations = if total <= 48 { 20 } else { 5 }; + let tracker = &satellite; + let total_time = measure(iterations, || { + let constellation = + Constellation::walker(pattern, epoch).expect("the pattern is consistent"); + // Propagate every satellite once per second of the track, at + // the sample step, which is the work a coverage run does. + let mut propagated = 0_usize; + for _orbit in constellation.satellites() { + for index in 0..samples { + let seconds = index as f64 * TRACK_STEP_SECONDS; + let state = tracker + .propagate_minutes(seconds / 60.0) + .expect("the track propagates"); + black_box(state.position); + propagated += 1; + } + } + propagated + }); + row( + "constellation propagate", + total, + iterations, + total_time, + total_time.as_nanos() as f64 / (iterations as f64 * (total * samples) as f64), + ); + } + } + + // ---- 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> = (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 total_time = 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, + iterations, + total_time, + total_time.as_nanos() as f64 / (iterations as f64 * satellites as f64), + ); + } + } + + // ---- 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 = (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 total_time = 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, + iterations, + total_time, + total_time.as_nanos() as f64 / (iterations as f64 * satellites as f64), + ); + } + } + + // ---- 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 = (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 total_time = measure(iterations, || { + let result = planner.schedule_with_precedence(&set, &resources, &graph); + result.schedule.len() + }); + row( + "plan tasks", + tasks, + iterations, + total_time, + total_time.as_nanos() as f64 / (iterations as f64 * tasks as f64), + ); + } + } + + println!(); + println!("ns per unit is the figure to watch: a rising trend means super-linear growth"); +} diff --git a/docs/design/errors.md b/docs/design/errors.md index c2475f5..2605c6a 100644 --- a/docs/design/errors.md +++ b/docs/design/errors.md @@ -115,6 +115,33 @@ 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`,包括构造失败与恢复路径。