workspace: add the scale benchmark and state the expect convention

bench_scale sweeps the input size over four paths and reports time per
unit, so growth is visible even when absolute times move with the machine.
It shows SGP4 propagation and access search are flat per satellite, while
topology construction is quadratic and planning is quadratic in the number
of tasks.

The README said library code never uses expect, but twelve call sites do,
each guarded by a check on the line above or by construction. Allow that
case explicitly and record each site with its guard in the design notes.
This commit is contained in:
ApsisRS contributors 2026-09-30 17:25:59 +08:00
parent a730851ec2
commit 22016f35e9
5 changed files with 333 additions and 5 deletions

View File

@ -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.

View File

@ -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 必须有文档注释,注释自包含
## 目录

View File

@ -217,3 +217,7 @@ required-features = ["orbit"]
[[example]]
name = "dump_tle"
required-features = ["orbit"]
[[example]]
name = "bench_scale"
required-features = ["full"]

View File

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

View File

@ -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`,包括构造失败与恢复路径。