From 8b53cb1d1b8ff1a898ca1ec41ff6033cdbb93fd0 Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 23:22:34 +0800 Subject: [PATCH] ci: run the integration checks and add a dependency free benchmark The cross-layer integration checks were examples containing assertions that cargo test never ran, so a break in one layer's assumptions about another could pass CI. Run them explicitly. The benches/ and tests/ directories were empty placeholders that a virtual workspace does not compile, and benches/criterion named a third party crate this workspace forbids. Remove both and add a real zero dependency benchmark as a facade example. --- .github/workflows/ci.yml | 14 ++ README.md | 19 ++- benches/criterion/.gitkeep | 0 crates/apsis-rs/Cargo.toml | 4 + crates/apsis-rs/examples/bench.rs | 212 ++++++++++++++++++++++++++++++ tests/integration/.gitkeep | 0 6 files changed, 246 insertions(+), 3 deletions(-) delete mode 100644 benches/criterion/.gitkeep create mode 100644 crates/apsis-rs/examples/bench.rs delete mode 100644 tests/integration/.gitkeep diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47aa942..53b7ef7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,20 @@ jobs: run: cargo clippy --workspace --all-targets -- -D warnings - name: Test run: cargo test --workspace + - name: Integration checks + # The cross-layer checks are examples rather than tests, because they + # are also meant to be read and run by hand. `cargo test` does not run + # them, so without this step a change that breaks one layer's + # assumptions about another would pass CI silently. + run: | + 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: Verify dependency freedom run: | # Every package in the graph must be a workspace crate. `--prefix diff --git a/README.md b/README.md index 75c3a92..e30211b 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,6 @@ apsis-rs = { path = "...", features = ["orbit"] } - 默认禁止任何第三方 crate,包括开发依赖与 `build.rs` - 库代码不使用 `unwrap()` / `expect()` / `panic!` / `unsafe` - 公共 API 必须有文档注释,注释自包含 -- 测试放在各 crate 的 `tests/` 目录,与 `src/` 分离;测试为集成测试,只通过公共 API 访问被测 crate,不使用 `#[cfg(test)]` 内联测试模块 ## 目录 @@ -68,12 +67,26 @@ apsis-rs = { path = "...", features = ["orbit"] } apsis-rs/ ├── Cargo.toml ├── crates/ # Rust 工作区成员 -├── tests/ # 集成测试 -├── benches/ # 基准测试 +│ └── apsis-rs/ +│ ├── examples/ # 对拍导出与跨层集成检查 +│ └── ... ├── validation/ # Python 对拍验证 └── docs/design/ # 设计文档 ``` +测试在各 crate 的 `tests/` 目录,为集成测试,只通过公共 API 访问被测 crate。 +跨层集成检查与零依赖基准测试是 `apsis-rs` 的 example: + +```bash +# 跨层集成检查(CI 会执行) +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 + +# 热点路径基准(release 下运行) +cargo run --release -p apsis-rs --features full --example bench +``` + ## 许可 MIT OR Apache-2.0,见 `LICENSE-MIT` 与 `LICENSE-APACHE`。 diff --git a/benches/criterion/.gitkeep b/benches/criterion/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/crates/apsis-rs/Cargo.toml b/crates/apsis-rs/Cargo.toml index d93d1bb..3c57100 100644 --- a/crates/apsis-rs/Cargo.toml +++ b/crates/apsis-rs/Cargo.toml @@ -201,3 +201,7 @@ required-features = ["ops"] [[example]] name = "integrate_sim" required-features = ["sim"] + +[[example]] +name = "bench" +required-features = ["full"] diff --git a/crates/apsis-rs/examples/bench.rs b/crates/apsis-rs/examples/bench.rs new file mode 100644 index 0000000..7a0b406 --- /dev/null +++ b/crates/apsis-rs/examples/bench.rs @@ -0,0 +1,212 @@ +//! Micro benchmarks for the hot paths, with no external dependency. +//! +//! Rust's built in benchmarking harness is nightly only and the usual +//! alternative is a third party crate, which this workspace forbids. This +//! example is the replacement: it times each path with the standard +//! library's monotonic clock and prints a table. It is meant to be run in +//! release mode, where the timings are meaningful: +//! +//! ```text +//! cargo run --release -p apsis-rs --features full --example bench +//! ``` +//! +//! Each path is run enough times that the measured time is well above the +//! clock resolution, and the reported figure is the total divided by the +//! iteration count. The numbers are a guide to relative cost, not a +//! benchmark to compare across machines by absolute value. +//! +//! Only paths that a caller is likely to run in a loop are included: +//! propagation, ephemeris interpolation, access search, link budget, and +//! task planning. A path that runs once per program is not worth timing. + +use apsis_access::{AccessFinder, OrbitSample}; +use apsis_coord::{Ecef, Eci}; +use apsis_coverage::GroundPoint; +use apsis_ephemeris::Sampler; +use apsis_frames::EarthRotationAngle; +use apsis_geodesy::Geodetic; +use apsis_kepler::{GravityParameter, KeplerianElements}; +use apsis_link::{LinkBudget, LinkGeometry}; +use apsis_noise::NoiseTemperature; +use apsis_resource::{Resource, ResourceId, ResourceSet, Window}; +use apsis_rf::{Decibel, Gain, Power}; +use apsis_scheduler::Planner; +use apsis_sgp4::{GravityModel, Satellite}; +use apsis_task::{Task, TaskId}; +use apsis_tle::TwoLineElement; +use std::hint::black_box; +use std::time::Instant; + +/// The number of times each path is run. +/// +/// The value is chosen so that every measurement is at least a few +/// milliseconds, which puts it far above the clock resolution while keeping +/// the whole run under a second per path. +const ITERATIONS: u32 = 20_000; + +/// The element set used for the propagation and access paths. +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 number of seconds from the epoch the sample orbit starts at. +const EPOCH_MJD: f64 = 54_723.517_825_28; + +/// Runs a path a number of times and returns the mean duration in +/// nanoseconds. +/// +/// The closure runs once before the clock starts, so that any lazy +/// initialisation it does is not charged to the first iteration. +fn measure(iterations: u32, mut path: F) -> f64 +where + F: FnMut() -> T, +{ + // Warm up. + black_box(path()); + let start = Instant::now(); + for _ in 0..iterations { + black_box(path()); + } + let elapsed = start.elapsed(); + elapsed.as_nanos() as f64 / f64::from(iterations) +} + +/// Prints one row of the table. +fn report(name: &str, nanos: f64, iterations: u32) { + println!("{name:<44} {nanos:>12.1} ns ({iterations} iterations)"); +} + +fn main() { + println!("ApsisRS bench: mean time per call, release mode, no external dependency"); + println!("{:<44} {:>12} (iterations)", "path", "mean"); + + // ---- SGP4 propagation ------------------------------------------------- + let element = TwoLineElement::parse(ISS).expect("the sample parses"); + let satellite = + Satellite::from_element(&element, GravityModel::WGS72).expect("the orbit propagates"); + let nanos = measure(ITERATIONS, || { + satellite + .propagate_minutes(black_box(30.0)) + .expect("propagates") + }); + report("sgp4 propagate (30 min from epoch)", nanos, ITERATIONS); + + // ---- Two-body Keplerian conversion ------------------------------------ + let elements = KeplerianElements { + semi_major_axis: 7_000.0, + eccentricity: 0.01, + inclination: 0.9, + right_ascension: 0.5, + argument_of_perigee: 1.0, + true_anomaly: 0.3, + }; + let nanos = measure(ITERATIONS, || { + elements + .to_state(black_box(GravityParameter::WGS84)) + .expect("valid") + }); + report("kepler elements to state", nanos, ITERATIONS); + + // ---- Hermite interpolation -------------------------------------------- + let sampler = Sampler::new(0.0, 600.0, 60.0); + let ephemeris = sampler + .sample(|seconds| { + let state = satellite + .propagate_minutes(seconds / 60.0) + .expect("propagates"); + apsis_ephemeris::Sample::new(seconds, state.position, state.velocity) + }) + .expect("the grid is usable"); + let nanos = measure(ITERATIONS, || { + ephemeris.state_at(black_box(137.5)).expect("covered") + }); + report("ephemeris Hermite query", nanos, ITERATIONS); + + let nanos = measure(ITERATIONS, || { + satellite + .propagate_minutes(black_box(137.5) / 60.0) + .expect("propagates") + }); + report("sgp4 propagate (comparison query)", nanos, ITERATIONS); + + // ---- Earth-fixed rotation -------------------------------------------- + let angle = EarthRotationAngle::from_ut1_days(EPOCH_MJD - 51_544.5); + let eci = Eci::new(apsis_math::Vec3::new(7_000.0, 0.0, 0.0)); + let nanos = measure(ITERATIONS, || eci.to_ecef(black_box(angle)).vector()); + report("TEME to Earth-fixed rotation", nanos, ITERATIONS); + + // ---- Geodetic conversion --------------------------------------------- + let ecef = Ecef::new(apsis_math::Vec3::new(6_878_137.0, 0.0, 0.0)); + let nanos = measure(ITERATIONS, || { + ecef.to_geodetic(black_box(apsis_coord::WGS84)) + .expect("off axis") + }); + report("Earth-fixed to geodetic", nanos, ITERATIONS); + + // ---- Access search ---------------------------------------------------- + // A minute of samples of a low Earth orbit, which is the input an access + // search consumes. + let mut samples = Vec::with_capacity(120); + for index in 0..120 { + let seconds = f64::from(index) * 30.0; + let state = satellite + .propagate_minutes(seconds / 60.0) + .expect("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(); + samples.push(OrbitSample::new(seconds, fixed)); + } + let finder = AccessFinder { + station: GroundPoint::new(Geodetic::new(0.0, 0.0, 0.0), 10.0_f64.to_radians()), + sample_step_seconds: 30.0, + crossing_tolerance_seconds: 0.01, + }; + let nanos = measure(2_000, || finder.find(black_box(&samples))); + report("access window search (120 samples)", nanos, 2_000); + + // ---- Link budget ------------------------------------------------------ + let budget = LinkBudget { + transmit_power: Power::from_dbw(10.0).expect("finite"), + transmit_gain: Gain::from_dbi(30.0).expect("finite"), + transmit_loss: Decibel::from_db(1.0), + receive_gain: Gain::from_dbi(35.0).expect("finite"), + receive_loss: Decibel::from_db(0.5), + system_temperature: NoiseTemperature::from_kelvin(150.0), + bandwidth: 36.0e6, + geometry: LinkGeometry { + distance: 38.0e6, + frequency: 12.0e9, + }, + }; + let nanos = measure(ITERATIONS, || { + budget.carrier_to_noise_density().expect("usable") + }); + report("link budget carrier to noise", nanos, ITERATIONS); + + // ---- Task planning ---------------------------------------------------- + // One station and sixty tasks, which is a small but realistic planning + // cycle. The whole plan is timed rather than one placement, because a + // caller runs the planner once per cycle. + let mut station = Resource::new(ResourceId::new(0), 1.0); + station + .set_windows(&[Window::new(0.0, 6_000.0)]) + .expect("usable"); + let resources = ResourceSet::from_resources(vec![station]).expect("distinct"); + let tasks: Vec = (0..60) + .map(|index| { + let start = f64::from(index) * 100.0; + Task::new(TaskId::new(index as usize), 60.0) + .with_window(start, start + 90.0) + .with_priority(1.0 + f64::from(index % 5)) + .try_require(ResourceId::new(0), 1.0) + .expect("the resource is new") + }) + .collect(); + let planner = Planner::new(); + let nanos = measure(500, || planner.schedule(black_box(&tasks), &resources)); + report("plan 60 tasks on 1 resource", nanos, 500); + + println!(); + println!("figures are means over the stated iterations; compare paths, not machines"); +} diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep deleted file mode 100644 index e69de29..0000000