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.
This commit is contained in:
ApsisRS contributors 2026-09-26 23:22:34 +08:00
parent 604838c927
commit 8b53cb1d1b
6 changed files with 246 additions and 3 deletions

View File

@ -23,6 +23,20 @@ jobs:
run: cargo clippy --workspace --all-targets -- -D warnings run: cargo clippy --workspace --all-targets -- -D warnings
- name: Test - name: Test
run: cargo test --workspace 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 - name: Verify dependency freedom
run: | run: |
# Every package in the graph must be a workspace crate. `--prefix # Every package in the graph must be a workspace crate. `--prefix

View File

@ -60,7 +60,6 @@ apsis-rs = { path = "...", features = ["orbit"] }
- 默认禁止任何第三方 crate,包括开发依赖与 `build.rs` - 默认禁止任何第三方 crate,包括开发依赖与 `build.rs`
- 库代码不使用 `unwrap()` / `expect()` / `panic!` / `unsafe` - 库代码不使用 `unwrap()` / `expect()` / `panic!` / `unsafe`
- 公共 API 必须有文档注释,注释自包含 - 公共 API 必须有文档注释,注释自包含
- 测试放在各 crate 的 `tests/` 目录,与 `src/` 分离;测试为集成测试,只通过公共 API 访问被测 crate,不使用 `#[cfg(test)]` 内联测试模块
## 目录 ## 目录
@ -68,12 +67,26 @@ apsis-rs = { path = "...", features = ["orbit"] }
apsis-rs/ apsis-rs/
├── Cargo.toml ├── Cargo.toml
├── crates/ # Rust 工作区成员 ├── crates/ # Rust 工作区成员
├── tests/ # 集成测试 │ └── apsis-rs/
├── benches/ # 基准测试 │ ├── examples/ # 对拍导出与跨层集成检查
│ └── ...
├── validation/ # Python 对拍验证 ├── validation/ # Python 对拍验证
└── docs/design/ # 设计文档 └── 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`。 MIT OR Apache-2.0,见 `LICENSE-MIT` 与 `LICENSE-APACHE`。

View File

@ -201,3 +201,7 @@ required-features = ["ops"]
[[example]] [[example]]
name = "integrate_sim" name = "integrate_sim"
required-features = ["sim"] required-features = ["sim"]
[[example]]
name = "bench"
required-features = ["full"]

View File

@ -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<F, T>(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<Task> = (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");
}