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:
parent
c256dc0da2
commit
9482d63548
|
|
@ -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
|
||||
|
|
|
|||
19
README.md
19
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`。
|
||||
|
|
|
|||
|
|
@ -201,3 +201,7 @@ required-features = ["ops"]
|
|||
[[example]]
|
||||
name = "integrate_sim"
|
||||
required-features = ["sim"]
|
||||
|
||||
[[example]]
|
||||
name = "bench"
|
||||
required-features = ["full"]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
Loading…
Reference in New Issue