Compare commits
No commits in common. "71b006bcb3588051092fa6657a4223c475365c19" and "2acc95ff21388682dbff66899a365ec2a1dc2a84" have entirely different histories.
71b006bcb3
...
2acc95ff21
|
|
@ -18,13 +18,11 @@ jobs:
|
|||
- name: Format
|
||||
run: cargo fmt --all --check
|
||||
- name: Check
|
||||
# --all-features matters: the facade crate enables nothing by default,
|
||||
# so without it almost all of its code, and every example, is skipped.
|
||||
run: cargo check --workspace --all-targets --all-features
|
||||
run: cargo check --workspace --all-targets
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
- name: Test
|
||||
run: cargo test --workspace --all-features
|
||||
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
|
||||
|
|
@ -39,17 +37,6 @@ jobs:
|
|||
# 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: Documentation
|
||||
# Catches a doc link that no longer resolves, which is how a doc
|
||||
# comment drifts away from the code it describes.
|
||||
env:
|
||||
RUSTDOCFLAGS: -D warnings
|
||||
run: cargo doc --workspace --all-features --no-deps
|
||||
- name: Check the layer rule
|
||||
# README.md states that a crate may only depend on layers to its
|
||||
# left. Nothing else in the build enforces that, so a reverse
|
||||
# dependency would otherwise pass CI silently.
|
||||
run: bash scripts/check-layers.sh
|
||||
- name: Verify dependency freedom
|
||||
run: |
|
||||
# Every package in the graph must be a workspace crate. `--prefix
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ version = 4
|
|||
name = "apsis-access"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-coverage",
|
||||
"apsis-geodesy",
|
||||
"apsis-math",
|
||||
|
|
@ -15,6 +16,7 @@ dependencies = [
|
|||
name = "apsis-antenna"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-rf",
|
||||
]
|
||||
|
||||
|
|
@ -22,6 +24,7 @@ dependencies = [
|
|||
name = "apsis-atmosphere"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-rf",
|
||||
]
|
||||
|
||||
|
|
@ -29,6 +32,7 @@ dependencies = [
|
|||
name = "apsis-conflict"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-resource",
|
||||
"apsis-schedule",
|
||||
"apsis-task",
|
||||
|
|
@ -38,7 +42,9 @@ dependencies = [
|
|||
name = "apsis-constellation"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-kepler",
|
||||
"apsis-math",
|
||||
"apsis-sgp4",
|
||||
]
|
||||
|
||||
|
|
@ -46,6 +52,7 @@ dependencies = [
|
|||
name = "apsis-coord"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-frames",
|
||||
"apsis-geodesy",
|
||||
"apsis-math",
|
||||
|
|
@ -59,6 +66,7 @@ version = "0.1.0"
|
|||
name = "apsis-coverage"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-geodesy",
|
||||
"apsis-math",
|
||||
]
|
||||
|
|
@ -75,6 +83,7 @@ dependencies = [
|
|||
name = "apsis-ephemeris"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-kepler",
|
||||
"apsis-math",
|
||||
]
|
||||
|
|
@ -82,11 +91,15 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "apsis-event"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "apsis-frames"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-math",
|
||||
]
|
||||
|
||||
|
|
@ -94,12 +107,17 @@ dependencies = [
|
|||
name = "apsis-geodesy"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-math",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "apsis-handover"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-math",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "apsis-interference"
|
||||
|
|
@ -113,6 +131,7 @@ dependencies = [
|
|||
name = "apsis-isl"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-math",
|
||||
]
|
||||
|
||||
|
|
@ -120,7 +139,10 @@ dependencies = [
|
|||
name = "apsis-kepler"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-math",
|
||||
"apsis-time",
|
||||
"apsis-tle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -145,6 +167,7 @@ version = "0.1.0"
|
|||
name = "apsis-maneuver"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-kepler",
|
||||
"apsis-math",
|
||||
]
|
||||
|
|
@ -157,6 +180,8 @@ version = "0.1.0"
|
|||
name = "apsis-mesh"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-math",
|
||||
"apsis-topology",
|
||||
]
|
||||
|
||||
|
|
@ -172,21 +197,29 @@ dependencies = [
|
|||
name = "apsis-orbit"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-ephemeris",
|
||||
"apsis-kepler",
|
||||
"apsis-maneuver",
|
||||
"apsis-math",
|
||||
"apsis-sgp4",
|
||||
"apsis-time",
|
||||
"apsis-tle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "apsis-output"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "apsis-resource"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "apsis-rf"
|
||||
|
|
@ -199,6 +232,8 @@ dependencies = [
|
|||
name = "apsis-routing"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-math",
|
||||
"apsis-topology",
|
||||
]
|
||||
|
||||
|
|
@ -252,6 +287,7 @@ dependencies = [
|
|||
name = "apsis-scenario"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-resource",
|
||||
"apsis-task",
|
||||
]
|
||||
|
|
@ -260,6 +296,7 @@ dependencies = [
|
|||
name = "apsis-schedule"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-resource",
|
||||
"apsis-task",
|
||||
]
|
||||
|
|
@ -269,6 +306,7 @@ name = "apsis-scheduler"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-conflict",
|
||||
"apsis-core",
|
||||
"apsis-resource",
|
||||
"apsis-schedule",
|
||||
"apsis-task",
|
||||
|
|
@ -278,6 +316,7 @@ dependencies = [
|
|||
name = "apsis-sgp4"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-math",
|
||||
"apsis-time",
|
||||
"apsis-tle",
|
||||
|
|
@ -287,6 +326,7 @@ dependencies = [
|
|||
name = "apsis-sim"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-event",
|
||||
"apsis-output",
|
||||
"apsis-resource",
|
||||
|
|
@ -301,6 +341,7 @@ dependencies = [
|
|||
name = "apsis-sim-engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-event",
|
||||
]
|
||||
|
||||
|
|
@ -308,6 +349,7 @@ dependencies = [
|
|||
name = "apsis-task"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-resource",
|
||||
]
|
||||
|
||||
|
|
@ -315,6 +357,7 @@ dependencies = [
|
|||
name = "apsis-time"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-leap",
|
||||
]
|
||||
|
||||
|
|
@ -322,6 +365,7 @@ dependencies = [
|
|||
name = "apsis-tle"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-time",
|
||||
]
|
||||
|
||||
|
|
@ -329,6 +373,7 @@ dependencies = [
|
|||
name = "apsis-topology"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"apsis-core",
|
||||
"apsis-math",
|
||||
]
|
||||
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -17,15 +17,9 @@ foundation -> physics -> orbit -> rf -> network -> ops -> sim -> apsis-rs
|
|||
|
||||
约束:
|
||||
|
||||
- 依赖只能从左向右。下层可依赖其上任意层,上层不得依赖其下任意层。
|
||||
例如 `network` 可以依赖 `orbit`(向左),但 `orbit` 不得依赖 `network`
|
||||
(向右,即反向)。左向依赖不限层数,允许跨层,因为把 `orbit` 的值
|
||||
传到 `network` 时中间层未必提供中转类型。
|
||||
- `foundation` 的 `apsis-core` 是全局基准,任何层任何 crate 均可依赖它。
|
||||
`apsis-math` 同理,因为线性代数被各层共用。
|
||||
- 层内以该层的门面 crate(`apsis-orbit`、`apsis-rf`、`apsis-sim`)作为整合点。
|
||||
|
||||
这套规则由 `scripts/check-layers.sh` 强制,CI 会运行它。
|
||||
- 依赖只能从左向右,禁止反向依赖
|
||||
- 禁止跨层跳跃,例如 `orbit` 不得直接依赖 `network`
|
||||
- 层内允许依赖该层的基准 crate:`foundation` 内 `apsis-core` 为基准,其余 crate 可依赖它;其余层内部以各自的门面 crate(`apsis-orbit`、`apsis-rf`、`apsis-sim` 等)作为整合点
|
||||
|
||||
各层内容:
|
||||
|
||||
|
|
@ -76,7 +70,6 @@ apsis-rs/
|
|||
│ └── apsis-rs/
|
||||
│ ├── examples/ # 对拍导出与跨层集成检查
|
||||
│ └── ...
|
||||
├── scripts/ # 仓库检查脚本
|
||||
├── validation/ # Python 对拍验证
|
||||
└── docs/design/ # 设计文档
|
||||
```
|
||||
|
|
|
|||
|
|
@ -205,7 +205,3 @@ required-features = ["sim"]
|
|||
[[example]]
|
||||
name = "bench"
|
||||
required-features = ["full"]
|
||||
|
||||
[[example]]
|
||||
name = "dump_coverage"
|
||||
required-features = ["network"]
|
||||
|
|
|
|||
|
|
@ -1,154 +0,0 @@
|
|||
//! Writes the coverage geometry this workspace computes for a set of cases.
|
||||
//!
|
||||
//! The output is CSV on standard output, with a header row. It is consumed
|
||||
//! by `validation/python/constellation/check_coverage.py`, which recomputes
|
||||
//! the same quantities from the spherical triangle relations and compares.
|
||||
//!
|
||||
//! The cases are chosen to span the interesting geometry: a low Earth orbit
|
||||
//! at a low mask, where the footprint is small relative to the Earth; a
|
||||
//! higher orbit, where it is large; the equatorial and polar cases; and a
|
||||
//! zero mask, where the footprint reaches the horizon.
|
||||
//!
|
||||
//! Usage:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p apsis-rs --features network --example dump_coverage \
|
||||
//! > validation/data/expected/rust_coverage.csv
|
||||
//! ```
|
||||
|
||||
use apsis_coverage::{
|
||||
earth_central_angle, elevation_from_central_angle, footprint_area, footprint_central_angle,
|
||||
footprint_fraction, nadir_angle, slant_range,
|
||||
};
|
||||
use core::f64::consts::PI;
|
||||
|
||||
/// The WGS 84 equatorial radius, in kilometers.
|
||||
///
|
||||
/// The coverage geometry is spherical, so a single radius is used. The
|
||||
/// value comes from the shared constants rather than being repeated, so a
|
||||
/// change to the ellipsoid reaches here.
|
||||
const BODY_RADIUS_KM: f64 = apsis_core::constant::EARTH_EQUATORIAL_RADIUS;
|
||||
|
||||
/// One coverage case: an orbital radius and an elevation mask.
|
||||
struct Case {
|
||||
/// A name for the case, so a failure in the report is readable.
|
||||
name: &'static str,
|
||||
/// The geocentric radius of the satellite, in kilometers.
|
||||
satellite_radius_km: f64,
|
||||
/// The elevation mask at the ground point, in degrees.
|
||||
elevation_mask_degrees: f64,
|
||||
}
|
||||
|
||||
/// The cases checked.
|
||||
///
|
||||
/// The satellite radii span a low Earth orbit, two higher altitudes, and a
|
||||
/// geostationary one. The masks span the horizon, a typical station mask, and
|
||||
/// a strict one.
|
||||
const CASES: &[Case] = &[
|
||||
Case {
|
||||
name: "leo_low_mask",
|
||||
satellite_radius_km: 6_928.137,
|
||||
elevation_mask_degrees: 5.0,
|
||||
},
|
||||
Case {
|
||||
name: "leo_ten_degrees",
|
||||
satellite_radius_km: 6_928.137,
|
||||
elevation_mask_degrees: 10.0,
|
||||
},
|
||||
Case {
|
||||
name: "leo_strict",
|
||||
satellite_radius_km: 6_928.137,
|
||||
elevation_mask_degrees: 30.0,
|
||||
},
|
||||
Case {
|
||||
name: "meo",
|
||||
satellite_radius_km: 26_560.0,
|
||||
elevation_mask_degrees: 10.0,
|
||||
},
|
||||
Case {
|
||||
name: "geo",
|
||||
satellite_radius_km: 42_164.0,
|
||||
elevation_mask_degrees: 10.0,
|
||||
},
|
||||
Case {
|
||||
name: "horizon",
|
||||
satellite_radius_km: 6_928.137,
|
||||
elevation_mask_degrees: 0.0,
|
||||
},
|
||||
];
|
||||
|
||||
/// A set of central angles, in degrees, for the inverse relation check.
|
||||
///
|
||||
/// Each is fed to `elevation_from_central_angle` and the elevation it returns
|
||||
/// is fed back through `earth_central_angle`, so the pair must compose to the
|
||||
/// identity. The angles span zero to a little past the largest footprint the
|
||||
/// cases produce.
|
||||
const CENTRAL_ANGLES_DEGREES: &[f64] = &[0.0, 1.0, 5.0, 15.0, 30.0, 45.0, 60.0, 75.0];
|
||||
|
||||
fn main() {
|
||||
// The header names every column so that the Python side can read by name
|
||||
// rather than by position.
|
||||
println!(
|
||||
"case,satellite_radius_km,body_radius_km,elevation_mask_rad,\
|
||||
nadir_rad,central_rad,slant_range_km,footprint_central_rad,\
|
||||
footprint_area_km2,footprint_fraction"
|
||||
);
|
||||
|
||||
for case in CASES {
|
||||
let elevation = case.elevation_mask_degrees * PI / 180.0;
|
||||
let nadir = nadir_angle(case.satellite_radius_km, BODY_RADIUS_KM, elevation)
|
||||
.expect("the case geometry is usable");
|
||||
let central = earth_central_angle(case.satellite_radius_km, BODY_RADIUS_KM, elevation)
|
||||
.expect("the case geometry is usable");
|
||||
let range = slant_range(case.satellite_radius_km, BODY_RADIUS_KM, elevation)
|
||||
.expect("the case geometry is usable");
|
||||
let footprint_central =
|
||||
footprint_central_angle(case.satellite_radius_km, BODY_RADIUS_KM, elevation)
|
||||
.expect("the case geometry is usable");
|
||||
let area = footprint_area(case.satellite_radius_km, BODY_RADIUS_KM, elevation)
|
||||
.expect("the case geometry is usable");
|
||||
let fraction = footprint_fraction(case.satellite_radius_km, BODY_RADIUS_KM, elevation)
|
||||
.expect("the case geometry is usable");
|
||||
|
||||
println!(
|
||||
"{},{},{},{},{},{},{},{},{},{}",
|
||||
case.name,
|
||||
case.satellite_radius_km,
|
||||
BODY_RADIUS_KM,
|
||||
elevation,
|
||||
nadir,
|
||||
central,
|
||||
range,
|
||||
footprint_central,
|
||||
area,
|
||||
fraction
|
||||
);
|
||||
}
|
||||
|
||||
// The inverse relation, written with its own header so the Python side
|
||||
// can tell the two blocks apart by the first column.
|
||||
println!();
|
||||
println!("inverse_central_degrees,elevation_rad,round_trip_central_degrees");
|
||||
for degrees in CENTRAL_ANGLES_DEGREES {
|
||||
let central = degrees * PI / 180.0;
|
||||
// Use the equatorial radius as the satellite radius for the inverse
|
||||
// check, so the geometry is the degenerate one where the elevation is
|
||||
// always zero. A more useful check uses a satellite altitude.
|
||||
let satellite_radius = BODY_RADIUS_KM + 550.0;
|
||||
let Ok(elevation) =
|
||||
elevation_from_central_angle(satellite_radius, BODY_RADIUS_KM, central).ok_or(())
|
||||
else {
|
||||
// Beyond the horizon the inverse has no solution, which is
|
||||
// reported as an empty row rather than skipped, so the count of
|
||||
// rows stays fixed.
|
||||
println!("{degrees},,");
|
||||
continue;
|
||||
};
|
||||
let Ok(back) = earth_central_angle(satellite_radius, BODY_RADIUS_KM, elevation).ok_or(())
|
||||
else {
|
||||
println!("{degrees},,");
|
||||
continue;
|
||||
};
|
||||
println!("{degrees},{elevation},{}", back * 180.0 / PI);
|
||||
}
|
||||
}
|
||||
|
|
@ -41,9 +41,7 @@ use apsis_tle::TwoLineElement;
|
|||
use apsis_topology::{Topology, TopologySample, TopologySeries};
|
||||
|
||||
/// The Earth equatorial radius, in kilometers, used for the link geometry.
|
||||
///
|
||||
/// The value comes from the shared constants rather than being repeated.
|
||||
const BODY_RADIUS_KM: f64 = apsis_core::constant::EARTH_EQUATORIAL_RADIUS;
|
||||
const BODY_RADIUS_KM: f64 = 6_378.137;
|
||||
|
||||
/// The element set of a satellite in a 550 km orbit, used as the source of
|
||||
/// the propagated track.
|
||||
|
|
|
|||
|
|
@ -1,21 +1,13 @@
|
|||
//! Shared foundation types for the ApsisRS workspace: a general purpose
|
||||
//! error type, result aliases, physical constants, and the unit marker
|
||||
//! trait.
|
||||
//! Shared foundation types for the ApsisRS workspace: error handling,
|
||||
//! result aliases, physical constants, and the unit marker trait.
|
||||
//!
|
||||
//! This crate sits at the bottom of the dependency graph. It has no
|
||||
//! dependency on any other ApsisRS crate, so every other crate may use it.
|
||||
//!
|
||||
//! Three concerns live here:
|
||||
//!
|
||||
//! - [`Error`] and [`Result`] are a general purpose error carrying a coarse
|
||||
//! [`ErrorKind`]. They exist so that a function whose failure has no
|
||||
//! interesting structure can report it without inventing a type, and so
|
||||
//! that the workspace has a shape to fall back on. They are deliberately
|
||||
//! *not* the error type of the layers: a layer with a domain specific
|
||||
//! failure defines its own `Error` and `ErrorKind`, whose variants name
|
||||
//! the concrete conditions a caller would want to match on. See
|
||||
//! `docs/design/errors.md` for why, and for the conventions the domain
|
||||
//! errors follow.
|
||||
//! - [`Error`] and [`Result`] give the workspace a single error surface
|
||||
//! without pulling in an external error handling crate.
|
||||
//! - [`constant`] holds physical and astronomical constants expressed in
|
||||
//! SI units.
|
||||
//! - [`Quantity`] marks newtypes that carry a physical unit, so generic
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
//! Each quantity is a newtype over `f64` that carries its unit in the type
|
||||
//! system, so a length cannot be passed where a duration is expected. Units
|
||||
//! of the same dimension convert through
|
||||
//! [`Quantity::to_unit`], and each unit also
|
||||
//! [`Quantity::to_unit`](apsis_core::Quantity::to_unit), and each unit also
|
||||
//! offers direct conversions for the common cases.
|
||||
//!
|
||||
//! A newtype implements both [`Unit`] and [`Quantity`], with its own type as
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
apsis-geodesy.workspace = true
|
||||
apsis-coverage.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,5 +11,7 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
apsis-kepler.workspace = true
|
||||
apsis-sgp4.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
apsis-geodesy.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,3 +11,5 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,4 +11,6 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
apsis-topology.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,4 +11,6 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
apsis-topology.workspace = true
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//!
|
||||
//! The topology of a satellite network changes as the satellites move, so a
|
||||
//! route is only valid over an interval. This crate computes least cost
|
||||
//! routes through a [`Topology`] and reports the
|
||||
//! routes through a [`Topology`](apsis_topology::Topology) and reports the
|
||||
//! route together with the time it remains valid.
|
||||
//!
|
||||
//! Two forms of routing are provided:
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-resource.workspace = true
|
||||
apsis-task.workspace = true
|
||||
apsis-schedule.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,3 +11,4 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-resource.workspace = true
|
||||
apsis-task.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-resource.workspace = true
|
||||
apsis-task.workspace = true
|
||||
apsis-schedule.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-resource.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
//! two-body theory, or read from a file all work the same way.
|
||||
//! - [`Sampler`] fills a table by calling a function that produces a state
|
||||
//! from a time. In this workspace that function is normally
|
||||
//! `apsis_sgp4::Satellite::propagate_at`, but the sampler only requires
|
||||
//! [`apsis_sgp4::Satellite::propagate_at`], but the sampler only requires
|
||||
//! a closure, so a test can use an analytic function.
|
||||
//!
|
||||
//! Interpolation is Hermite cubic, which uses both the position and the
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ impl Sampler {
|
|||
///
|
||||
/// The function maps a time in seconds to a position and velocity. In
|
||||
/// this workspace it is normally a closure over
|
||||
/// `apsis_sgp4::Satellite::propagate_at`, but any function works, so
|
||||
/// [`apsis_sgp4::Satellite::propagate_at`], but any function works, so
|
||||
/// the sampler can be tested against an analytic orbit.
|
||||
///
|
||||
/// Returns an error when the sampler is not usable or when the function
|
||||
|
|
|
|||
|
|
@ -11,4 +11,7 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
apsis-time.workspace = true
|
||||
apsis-tle.workspace = true
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ pub fn eccentric_from_true(true_anomaly: f64, eccentricity: f64) -> f64 {
|
|||
/// by Newton's method. The starting guess is the mean anomaly plus a term
|
||||
/// proportional to the eccentricity, which keeps the iteration stable for
|
||||
/// eccentricities approaching one. The iteration always terminates, and the
|
||||
/// result is the value reached after at most a fixed number of steps.
|
||||
/// result is the value reached after at most [`MAX_ITERATIONS`] steps.
|
||||
pub fn eccentric_from_mean(mean_anomaly: f64, eccentricity: f64) -> f64 {
|
||||
let mean = wrap_to_pi(mean_anomaly);
|
||||
// A first order correction to the circular guess.
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
apsis-kepler.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
apsis-time.workspace = true
|
||||
apsis-tle.workspace = true
|
||||
apsis-sgp4.workspace = true
|
||||
apsis-kepler.workspace = true
|
||||
|
|
|
|||
|
|
@ -222,7 +222,6 @@ impl Orbit {
|
|||
|
||||
/// A failure while sampling an orbit into an ephemeris.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum EphemerisSamplingError {
|
||||
/// The requested grid is not usable: the step is not positive or the
|
||||
/// range is empty or reversed.
|
||||
|
|
@ -248,7 +247,6 @@ impl core::error::Error for EphemerisSamplingError {}
|
|||
|
||||
/// A failure while applying a burn to an orbit.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum ManeuverApplicationError {
|
||||
/// The state before the burn could not be propagated.
|
||||
Propagation(PropagationError),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
apsis-tle.workspace = true
|
||||
apsis-time.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-time.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
apsis-geodesy.workspace = true
|
||||
apsis-frames.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ pub(crate) fn rotation_x(angle: f64) -> Mat3 {
|
|||
|
||||
/// Returns a rotation of `angle` about the second axis, in radians.
|
||||
///
|
||||
/// The sign convention matches the other axis rotations in this module.
|
||||
/// The convention is the one of [`rotation_x`].
|
||||
fn rotation_y(angle: f64) -> Mat3 {
|
||||
let (sine, cosine) = angle.sin_cos();
|
||||
Mat3::from_rows([cosine, 0.0, -sine], [0.0, 1.0, 0.0], [sine, 0.0, cosine])
|
||||
|
|
@ -173,7 +173,7 @@ fn rotation_y(angle: f64) -> Mat3 {
|
|||
|
||||
/// Returns a rotation of `angle` about the third axis, in radians.
|
||||
///
|
||||
/// The sign convention matches the other axis rotations in this module.
|
||||
/// The convention is the one of [`rotation_x`].
|
||||
pub fn rotation_z(angle: f64) -> Mat3 {
|
||||
let (sine, cosine) = angle.sin_cos();
|
||||
Mat3::from_rows([cosine, sine, 0.0], [-sine, cosine, 0.0], [0.0, 0.0, 1.0])
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-math.workspace = true
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
//! differ by up to about 0.19 degrees at mid latitudes, so the distinction
|
||||
//! matters for pointing and ranging.
|
||||
//!
|
||||
//! [`WGS84`] is the ellipsoid used by GPS and by almost all satellite work.
|
||||
//! [`Wgs84`] is the ellipsoid used by GPS and by almost all satellite work.
|
||||
//! Its defining parameters are the semi-major axis and the inverse
|
||||
//! flattening; every other quantity is derived from those two, so the
|
||||
//! derived values agree with the ones published in the standard to the
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-leap.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-rf.workspace = true
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
//! uniformly illuminated circular aperture it is about minus 17.6 dB.
|
||||
//!
|
||||
//! Every function here takes or returns angles in radians and gains as
|
||||
//! [`apsis_rf::Gain`], so a result can be fed straight into a link budget.
|
||||
//! [`Gain`], so a result can be fed straight into a link budget.
|
||||
//!
|
||||
//! Sources:
|
||||
//!
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-rf.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,3 +11,4 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,3 +11,4 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-resource.workspace = true
|
||||
apsis-task.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-event.workspace = true
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
//! implementation meaningful.
|
||||
//!
|
||||
//! The engine stops for one of four reasons, reported as a
|
||||
//! [`StopReason`]:
|
||||
//! [`StopReason`](crate::StopReason):
|
||||
//! the queue empties, the simulation reaches its time limit, the caller asks
|
||||
//! it to stop, or an event limit is reached. The last two exist so that a
|
||||
//! model that schedules events forever cannot run without bound.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-resource.workspace = true
|
||||
apsis-task.workspace = true
|
||||
apsis-schedule.workspace = true
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@
|
|||
## 跨层约定
|
||||
|
||||
- [单位与坐标约定](units.md)
|
||||
- [错误与容差策略](errors.md)
|
||||
- [分层边界](layers.md)
|
||||
|
||||
## 各层设计
|
||||
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
# 错误与容差策略
|
||||
|
||||
本文件记录跨 crate 的错误表达与数值容差约定。单个 crate 的错误枚举见其
|
||||
`error.rs` 的文档。
|
||||
|
||||
## 错误表达
|
||||
|
||||
### 领域错误
|
||||
|
||||
每个有领域特定失败的 crate 定义**自己的**错误类型,命名与结构一致:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum FooErrorKind {
|
||||
/// 每个变体描述一个调用方可能想要区分的具体条件。
|
||||
ReversedInterval,
|
||||
DuplicateResource,
|
||||
// ...
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FooError {
|
||||
kind: FooErrorKind,
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
impl FooError {
|
||||
pub fn new(kind: FooErrorKind) -> Self;
|
||||
pub fn with_detail(self, detail: impl Into<String>) -> Self;
|
||||
pub fn kind(&self) -> FooErrorKind;
|
||||
pub fn detail(&self) -> Option<&str>;
|
||||
}
|
||||
|
||||
impl fmt::Display for FooError { /* "kind" 或 "kind: detail" */ }
|
||||
impl core::error::Error for FooError {}
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- **`ErrorKind` 标注 `#[non_exhaustive]`**,这样新增一个失败类别不会破坏
|
||||
下游的匹配。当前 15 个 `ErrorKind` 全部标注。
|
||||
- **错误枚举本身也标注 `#[non_exhaustive]`**,包括同时携带数据的错误枚举
|
||||
(如 `apsis-orbit` 的 `EphemerisSamplingError`,其变体包裹下层错误)。
|
||||
- **非错误的状态枚举不标注。** 领域状态(`TaskState`、`LinkState`、
|
||||
`TimeScale`、`HandoverDecision`、`Pattern` 的选择等)在单仓库
|
||||
工作区内以**可穷尽匹配为优点**:新增一个变体时,编译器会指出每一处需要
|
||||
更新的 `match`。这与错误的取舍相反——错误类别多一个,调用方应能继续
|
||||
走默认分支;状态多一个,调用方通常必须显式处理。判断标准:该 enum 的
|
||||
变体是否会被调用方 `match` 后对某些分支走默认?会,则标;不会,则不标。
|
||||
- **`kind` 可精确匹配,`detail` 供人阅读。** 调用方按 `kind` 写恢复逻辑,
|
||||
把 `detail` 记入日志。`detail` 不参与匹配,因此其措辞可自由改动。
|
||||
- **`Display` 输出 `kind`,有 `detail` 时追加 `": detail"`。** 这样日志行
|
||||
总是以类别开头。
|
||||
- **实现 `core::error::Error`**,以便错误可以嵌套为 `source`。当前 15 个
|
||||
错误类型全部实现。
|
||||
- **不返回裸 `String` 或 `Box<dyn Error>` 作为公共错误。** 前者无法匹配,
|
||||
后者丢失类别。私有辅助函数内部可以传 `String` 作为原因文本,但公开
|
||||
边界必须是枚举。`apsis-scheduler` 的 `precedence_floor` 是这种内部用法
|
||||
的实例:它私有的 `String` 最终填入公开的 `TaskState::Infeasible { reason }`。
|
||||
|
||||
### 通用错误
|
||||
|
||||
`apsis-core::Error` 与 `ErrorKind` 是通用兜底类型,携带粗粒度类别
|
||||
(`InvalidInput`、`NotFound` 等)。它**不是**各层的统一错误表面:没有任何
|
||||
层使用它,因为通用类别丢失了调用方需要的区分(`InvalidInput` 无法区分
|
||||
"区间反向"与"重复标识")。
|
||||
|
||||
它保留在这里有两个理由:给一个失败没有有趣结构的函数一个现成的返回类型;
|
||||
以及给工作区一个可扩展的兜底形状。新代码若失败有领域含义,应定义领域错误。
|
||||
|
||||
### 失败与"无值"的区分
|
||||
|
||||
- **`Option`** 表示"没有值"是正常结果,不是错误。例如某个时刻在覆盖区间
|
||||
之外(`Ephemeris::state_at` 返回 `None`)、某个角度的正切无定义。
|
||||
- **`Result`** 表示操作因可诊断的原因失败。例如区间反向、资源重复、
|
||||
超出模型定义域。
|
||||
|
||||
判断标准:调用方是否需要知道**为什么**。若只需要知道"没有",用 `Option`;
|
||||
若需要区分失败原因并据此恢复,用 `Result`。
|
||||
|
||||
## 数值容差
|
||||
|
||||
### 迭代收敛容差
|
||||
|
||||
迭代求解(开普勒方程、章动级数、射线追踪)用 crate 内的私有常量控制收敛:
|
||||
|
||||
| crate | 常量 | 值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `apsis-kepler` | `TOLERANCE` | `1e-14` | 偏近点角牛顿迭代的步长 |
|
||||
| `apsis-sgp4` | `KEPLER_TOLERANCE` | `1e-12` | 开普勒方程迭代残差 |
|
||||
|
||||
这些值在浮点精度与迭代次数之间折中:取到接近 `f64` 的机器精度,同时
|
||||
保证迭代在有限步内收敛(各有对应的 `MAX_ITERATIONS`)。
|
||||
|
||||
### 对拍容差
|
||||
|
||||
对拍脚本的容差按**物理量**设定,而不是统一的数值:
|
||||
|
||||
| 量 | 容差 |
|
||||
| --- | --- |
|
||||
| 位置 | < 1e-3 km |
|
||||
| 速度 | < 1e-6 km/s |
|
||||
| 时间 | < 1e-6 s |
|
||||
| 覆盖几何角度 | 1e-12 rad |
|
||||
| 覆盖面积、占比 | 相对 1e-12 |
|
||||
|
||||
容差设在"远高于观测到的偏差(通常在浮点精度量级),远低于模型本身的
|
||||
精度"之间。这样既能捕获半径写错、单位漏换算这类错误,又不会因最后一位
|
||||
浮点差异而误报。详见 `validation/README.md` 的容差表。
|
||||
|
||||
### 精确比较
|
||||
|
||||
不需要容差时应避免引入:整数索引、计数、布尔标志用精确相等;角度归约
|
||||
(`wrap_to_pi`)在边界上用半开区间,不引入 epsilon。浮点比较仅在量本身
|
||||
是连续近似时使用。
|
||||
|
||||
## 检查
|
||||
|
||||
- 各 crate 测试覆盖其错误的每个 `kind`,包括构造失败与恢复路径。
|
||||
- 对拍脚本以退出码表达结果:0 通过、1 超差、2 缺输入。
|
||||
- `cargo clippy -- -D warnings` 保证没有 `unwrap`/`expect` 漏进库代码
|
||||
(工作区约定库代码不使用它们)。
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
# 分层边界
|
||||
|
||||
本文件记录工作区的分层规则,以及为什么某些跨层依赖是允许的。README 的
|
||||
"分层"一节是规则本身,这里是它的判定标准与边界情况。
|
||||
|
||||
## 规则
|
||||
|
||||
层自左向右排列:
|
||||
|
||||
```text
|
||||
foundation -> physics -> orbit -> rf -> network -> ops -> sim -> apsis-rs
|
||||
```
|
||||
|
||||
一条依赖边从 crate A 指向 crate B。把 A 所在的层与 B 所在的层分别映射为
|
||||
下标,规则是:
|
||||
|
||||
```text
|
||||
index(B) <= index(A) 允许
|
||||
index(B) > index(A) 违规(反向依赖)
|
||||
```
|
||||
|
||||
即**只能向左依赖,不能向右依赖**。`scripts/check-layers.sh` 对工作区每个
|
||||
清单执行这条判定,CI 会运行它。
|
||||
|
||||
## 允许跨层
|
||||
|
||||
规则只看方向,不看距离。从 `network` 直接依赖 `orbit` 是允许的,尽管中间
|
||||
隔着 `rf`:`orbit` 在 `network` 左边,方向正确。
|
||||
|
||||
允许向左跨层是有意的。若强制只能依赖紧邻的左层,会产生两类问题:
|
||||
|
||||
- **中转类型不存在。** 把 `orbit` 层的位置传给 `network` 层时,`rf` 层并
|
||||
非必然提供一个承载该位置的类型。强行要求经过 `rf` 只会制造只是转发的
|
||||
空壳类型。
|
||||
- **无谓的耦合。** `network` 的星座计算需要 `apsis-kepler` 的开普勒要素,
|
||||
它并不需要 `rf`。若禁止跨层,`network` 就得依赖 `rf` 只为拿到一个它不
|
||||
使用的类型。
|
||||
|
||||
因此规则约束的是**方向**,因为方向才对应真正的分层语义:上层的概念可以
|
||||
引用下层的概念,反之则说明分层错了。
|
||||
|
||||
## 全局基准
|
||||
|
||||
`apsis-core`(通用错误、常量、单位标记)与 `apsis-math`(`Vec3`、
|
||||
`Mat3`、`Quat`)被各层共用。它们本身在 `foundation` 中,所以"只向左依赖"
|
||||
已经允许任何层使用它们,无需特例。
|
||||
|
||||
这与"层内基准"不同:`apsis-core` 也是 `foundation` 内的基准,但它的可用
|
||||
范围是整个工作区,而不只是同层。
|
||||
|
||||
**只有真正使用的依赖才声明。** `apsis-core` 与 `apsis-math` 可以被任何
|
||||
crate 使用,但一个不引用它们的 crate 不应把它们写进清单:未使用的依赖会
|
||||
拖慢编译、让 `cargo tree` 与分层检查产生噪声,并让"谁依赖什么"失真。
|
||||
需要时再加,是一条随时可走的单行改动。`scripts/check-layers.sh` 报告的
|
||||
边数因此是真实的使用关系,而不是一份预留清单。
|
||||
|
||||
## 层内门面
|
||||
|
||||
每层以一个门面 crate 作为整合点:
|
||||
|
||||
| 层 | 门面 |
|
||||
| --- | --- |
|
||||
| orbit | `apsis-orbit` |
|
||||
| rf | `apsis-rf` |
|
||||
| sim | `apsis-sim` |
|
||||
| 对外 | `apsis-rs` |
|
||||
|
||||
门面把同层的能力组合成少量类型,并再导出同层的其余 crate。`foundation`、
|
||||
`physics`、`network`、`ops` 没有单独门面:它们的 crate 各自独立有用,且
|
||||
`apsis-rs` 会把它们一并再导出。
|
||||
|
||||
## 边界情况
|
||||
|
||||
- **facade `apsis-rs` 不在层序列中。** 它依赖全部层,因为它就是出口。
|
||||
`scripts/check-layers.sh` 通过层目录名判定,`crates/apsis-rs` 不是
|
||||
`crates/<layer>/<crate>` 形状,因此被跳过。
|
||||
- **`dev-dependencies` 也计入检查。** 一条测试里的反向依赖同样说明分层
|
||||
错了,应当修正代码而不是放宽测试。
|
||||
- **门面再导出不构成新依赖。** `apsis-rs` 把 `apsis-orbit` 再导出,是
|
||||
门面行为,不是某层对 `orbit` 的依赖。
|
||||
|
||||
## 检查
|
||||
|
||||
```bash
|
||||
bash scripts/check-layers.sh
|
||||
```
|
||||
|
||||
脚本输出检查过的依赖边数,并在发现反向依赖时逐条列出后以退出码 1 结束。
|
||||
对当前工作区,它检查 104 条边,全部方向正确。
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Checks the layer dependency rule stated in README.md.
|
||||
#
|
||||
# The workspace is organized into dependency ordered layers:
|
||||
#
|
||||
# foundation -> physics -> orbit -> rf -> network -> ops -> sim
|
||||
#
|
||||
# A crate may depend on any crate in a layer to its left, but never on one to
|
||||
# its right. This script reads every workspace manifest, works out which
|
||||
# layer each dependency belongs to, and fails if any dependency points to the
|
||||
# right.
|
||||
#
|
||||
# `apsis-core` and `apsis-math` are exempt as global prerequisites: they sit
|
||||
# in `foundation` and every layer may use them, which the left-only rule
|
||||
# already permits, so no special case is needed for them.
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# scripts/check-layers.sh
|
||||
#
|
||||
# Exit status is zero when the rule holds and one otherwise.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# The layer order, left to right. A dependency to a layer with a lower index
|
||||
# is allowed; to a higher index is a reverse dependency and fails.
|
||||
LAYERS=(foundation physics orbit rf network ops sim)
|
||||
|
||||
# The root of the workspace, taken from this script's location so that the
|
||||
# check works from any working directory.
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$root"
|
||||
|
||||
# Returns the index of a layer name, or an empty string when the name is not
|
||||
# a layer. A crate outside these directories is not part of the layer scheme
|
||||
# and is skipped rather than reported.
|
||||
layer_index() {
|
||||
local name="$1"
|
||||
local index=0
|
||||
for layer in "${LAYERS[@]}"; do
|
||||
if [ "$layer" = "$name" ]; then
|
||||
echo "$index"
|
||||
return 0
|
||||
fi
|
||||
index=$((index + 1))
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
|
||||
failures=0
|
||||
checked=0
|
||||
|
||||
for manifest in $(find crates -name Cargo.toml | sort); do
|
||||
directory="$(dirname "$manifest")"
|
||||
# The layer directory is the second path component, as in
|
||||
# `crates/<layer>/<crate>/Cargo.toml`.
|
||||
layer="$(echo "$directory" | cut -d/ -f2)"
|
||||
source_index="$(layer_index "$layer")"
|
||||
[ -z "$source_index" ] && continue
|
||||
|
||||
# Every `apsis-...` name in the manifest is a dependency or a package
|
||||
# name. The package name is the crate itself, which resolves to the same
|
||||
# layer and is skipped by the index comparison below.
|
||||
for dependency in $(grep -oE 'apsis-[a-z-]+' "$manifest" | sort -u); do
|
||||
dependency_directory="$(find crates -maxdepth 2 -type d -name "$dependency" 2>/dev/null | head -1)"
|
||||
[ -z "$dependency_directory" ] && continue
|
||||
dependency_layer="$(echo "$dependency_directory" | cut -d/ -f2)"
|
||||
dependency_index="$(layer_index "$dependency_layer")"
|
||||
[ -z "$dependency_index" ] && continue
|
||||
|
||||
if [ "$dependency_index" -gt "$source_index" ]; then
|
||||
echo "reverse dependency: $layer -> $dependency_layer ($(basename "$directory") -> $dependency)" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
checked=$((checked + 1))
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$failures" -gt 0 ]; then
|
||||
echo "layer check failed: $failures reverse dependency(ies)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "layer check passed: $checked dependency edge(s), none reversed"
|
||||
|
|
@ -32,8 +32,9 @@ validation/
|
|||
│ ├── time/ # 时间尺度、儒略日对拍
|
||||
│ ├── coord/ # 坐标变换对拍
|
||||
│ ├── link/ # 链路预算对拍
|
||||
│ └── constellation/ # 覆盖几何对拍
|
||||
│ └── constellation/ # 星座覆盖对拍
|
||||
└── data/
|
||||
├── tle/ # 公开 TLE 样本
|
||||
└── expected/ # Rust 侧导出的待比对数据
|
||||
```
|
||||
|
||||
|
|
@ -89,10 +90,6 @@ cargo run -p apsis-rs --features physics --example dump_iau2000 \
|
|||
# TEME → 地固系(SGP4 输出链路)
|
||||
cargo run -p apsis-rs --features physics --example dump_teme \
|
||||
> validation/data/expected/rust_teme.csv
|
||||
|
||||
# 覆盖几何(仰角、星下点角、地心角、斜距、覆盖面积)
|
||||
cargo run -p apsis-rs --features network --example dump_coverage \
|
||||
> validation/data/expected/rust_coverage.csv
|
||||
```
|
||||
|
||||
Python 侧读取同一批输入,用参考实现重算并输出误差表:
|
||||
|
|
@ -110,7 +107,6 @@ pixi run python validation/python/atmosphere/check_p839.py
|
|||
pixi run python validation/python/atmosphere/check_gas_exact.py
|
||||
pixi run python validation/python/time/check_iau2000.py
|
||||
pixi run python validation/python/coord/check_teme.py
|
||||
pixi run python validation/python/constellation/check_coverage.py
|
||||
```
|
||||
|
||||
两个脚本都以退出码 0 表示通过、1 表示超差、2 表示缺少输入文件。
|
||||
|
|
@ -119,16 +115,9 @@ pixi run python validation/python/constellation/check_coverage.py
|
|||
|
||||
| 量 | 容差 |
|
||||
| --- | --- |
|
||||
| 位置、距离 | < 1e-3 km(SGP4 传播)或 1e-9 km(几何量) |
|
||||
| 位置 | < 1e-3 km |
|
||||
| 速度 | < 1e-6 km/s |
|
||||
| 时间 | < 1e-6 s |
|
||||
| 角度(覆盖几何) | 1e-12 rad |
|
||||
| 面积、覆盖占比 | 相对 1e-12 |
|
||||
| 链路预算、衰减 | 1e-6 至 1e-9 dB,视模型而定 |
|
||||
| 地球定向矩阵 | 1e-9 |
|
||||
|
||||
各脚本把容差写成其源文件顶部的具名常量,值按所比较的物理量设定,见
|
||||
`docs/design/errors.md` 的"对拍容差"一节。
|
||||
|
||||
超差时修正 Rust 侧算法,不放宽容差,除非经确认。
|
||||
|
||||
|
|
@ -151,7 +140,6 @@ Earth orientation 数据自动下载;本 crate 涉及的换算不需要该数
|
|||
| `atmosphere/check_gas_exact.py` | 精确斜路径,11 个场景(1–100 GHz,1°–90° 仰角) | `itur` 的 ITU-R P.676-12 exact 方法 | 9.6e-9(相对) |
|
||||
| `time/check_iau2000.py` | 岁差、章动、框架偏移、GAST、GCRS→ITRF,8 个历元 × 28 个量 | ERFA 2.0.1(IAU SOFA 派生) | 章动 6.6e-15 rad;BPN 矩阵 6.0e-15;GAST 1.1e-11 rad |
|
||||
| `coord/check_teme.py` | TEME → 地固系,4 个历元 | ERFA 的 `gmst82` 与 `pom00`(同为 0 极移) | 4.9e-10 km |
|
||||
| `constellation/check_coverage.py` | 覆盖几何,6 个场景(LEO/MEO/GEO × 低/常规/严格遮蔽角);以及地心角与仰角的往返 | 球面三角关系(Python 从几何独立重写) | 角度 0,面积相对 2.2e-16 |
|
||||
|
||||
### 关于射频层对拍的说明
|
||||
|
||||
|
|
|
|||
|
|
@ -1,227 +0,0 @@
|
|||
"""Cross-check the ApsisRS coverage geometry against spherical trigonometry.
|
||||
|
||||
Reads the quantities written by ``cargo run -p apsis-rs --features network
|
||||
--example dump_coverage`` and recomputes each one from the spherical
|
||||
triangle relations using only the standard library and numpy. The point is
|
||||
that the Python side is written from the geometry, not from the Rust code,
|
||||
so an error in the Rust formula shows up as a disagreement.
|
||||
|
||||
The three angles of the triangle formed by the satellite, the ground point,
|
||||
and the centre of the Earth satisfy
|
||||
|
||||
elevation + nadir + central = 90 degrees
|
||||
|
||||
and the law of sines gives
|
||||
|
||||
sin(nadir) / body_radius = cos(elevation) / satellite_radius
|
||||
|
||||
from which the nadir angle, the central angle, and the slant range follow.
|
||||
The footprint is the spherical cap the central angle cuts from the body, so
|
||||
its area is a zone area and its fraction is that over the whole surface.
|
||||
|
||||
The script needs no network access.
|
||||
|
||||
Usage:
|
||||
|
||||
pixi run python validation/python/constellation/check_coverage.py [path]
|
||||
|
||||
Exit status is zero when every quantity agrees within tolerance and one
|
||||
otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# The agreement is at the floating point level because both sides evaluate
|
||||
# the same closed form expressions. The tolerances are set to catch a
|
||||
# transposed radius or a missing conversion, not to allow a loose match.
|
||||
ANGLE_TOLERANCE_RAD = 1e-12
|
||||
LENGTH_TOLERANCE_KM = 1e-9
|
||||
AREA_RELATIVE_TOLERANCE = 1e-12
|
||||
FRACTION_TOLERANCE = 1e-12
|
||||
|
||||
DEFAULT_INPUT = Path("validation/data/expected/rust_coverage.csv")
|
||||
|
||||
|
||||
def reference_nadir(satellite_radius: float, body_radius: float, elevation: float) -> float:
|
||||
"""Returns the nadir angle from the law of sines."""
|
||||
return math.asin(body_radius * math.cos(elevation) / satellite_radius)
|
||||
|
||||
|
||||
def reference_central(satellite_radius: float, body_radius: float, elevation: float) -> float:
|
||||
"""Returns the central angle from the angle sum."""
|
||||
nadir = reference_nadir(satellite_radius, body_radius, elevation)
|
||||
return max(0.0, math.pi / 2.0 - elevation - nadir)
|
||||
|
||||
|
||||
def reference_range(satellite_radius: float, body_radius: float, elevation: float) -> float:
|
||||
"""Returns the slant range from the law of cosines."""
|
||||
central = reference_central(satellite_radius, body_radius, elevation)
|
||||
squared = (
|
||||
satellite_radius**2
|
||||
+ body_radius**2
|
||||
- 2.0 * satellite_radius * body_radius * math.cos(central)
|
||||
)
|
||||
return math.sqrt(squared)
|
||||
|
||||
|
||||
def reference_cap_area(central: float, body_radius: float) -> float:
|
||||
"""Returns the area of a spherical cap of a given central angle.
|
||||
|
||||
A cap is a zone between the pole and a colatitude, so its area is
|
||||
``2 pi R^2 (1 - cos(central))``. The footprint is not centred on a pole in
|
||||
general, but a spherical cap is rotationally symmetric, so the same
|
||||
expression applies.
|
||||
"""
|
||||
return 2.0 * math.pi * body_radius**2 * (1.0 - math.cos(central))
|
||||
|
||||
|
||||
def read_blocks(path: Path):
|
||||
"""Reads the two blocks of the file.
|
||||
|
||||
The first block is the case table with a header row; a blank line
|
||||
separates it from the inverse relation block, which has its own header.
|
||||
"""
|
||||
text = path.read_text().splitlines()
|
||||
blocks: list[list[str]] = [[]]
|
||||
for line in text:
|
||||
if line.strip():
|
||||
blocks[-1].append(line)
|
||||
else:
|
||||
blocks.append([])
|
||||
return [block for block in blocks if block]
|
||||
|
||||
|
||||
def check_cases(rows: list[dict[str, str]]) -> tuple[bool, list[str]]:
|
||||
"""Checks the case table and returns whether it passed and any failures."""
|
||||
failures: list[str] = []
|
||||
compared = 0
|
||||
worst = {
|
||||
"nadir": 0.0,
|
||||
"central": 0.0,
|
||||
"range": 0.0,
|
||||
"area": 0.0,
|
||||
"fraction": 0.0,
|
||||
}
|
||||
|
||||
for row in rows:
|
||||
name = row["case"]
|
||||
satellite_radius = float(row["satellite_radius_km"])
|
||||
body_radius = float(row["body_radius_km"])
|
||||
elevation = float(row["elevation_mask_rad"])
|
||||
compared += 1
|
||||
|
||||
nadir = reference_nadir(satellite_radius, body_radius, elevation)
|
||||
central = reference_central(satellite_radius, body_radius, elevation)
|
||||
slant = reference_range(satellite_radius, body_radius, elevation)
|
||||
area = reference_cap_area(central, body_radius)
|
||||
fraction = area / (4.0 * math.pi * body_radius**2)
|
||||
|
||||
worst["nadir"] = max(worst["nadir"], abs(nadir - float(row["nadir_rad"])))
|
||||
worst["central"] = max(worst["central"], abs(central - float(row["central_rad"])))
|
||||
worst["range"] = max(worst["range"], abs(slant - float(row["slant_range_km"])))
|
||||
rust_area = float(row["footprint_area_km2"])
|
||||
worst["area"] = max(worst["area"], abs(area - rust_area) / rust_area)
|
||||
worst["fraction"] = max(
|
||||
worst["fraction"], abs(fraction - float(row["footprint_fraction"]))
|
||||
)
|
||||
|
||||
if abs(nadir - float(row["nadir_rad"])) > ANGLE_TOLERANCE_RAD:
|
||||
failures.append(f"{name}: nadir differs by {abs(nadir - float(row['nadir_rad']))}")
|
||||
if abs(central - float(row["central_rad"])) > ANGLE_TOLERANCE_RAD:
|
||||
failures.append(
|
||||
f"{name}: central differs by {abs(central - float(row['central_rad']))}"
|
||||
)
|
||||
if abs(slant - float(row["slant_range_km"])) > LENGTH_TOLERANCE_KM:
|
||||
failures.append(
|
||||
f"{name}: range differs by {abs(slant - float(row['slant_range_km']))}"
|
||||
)
|
||||
if abs(area - rust_area) / rust_area > AREA_RELATIVE_TOLERANCE:
|
||||
failures.append(f"{name}: area differs by {abs(area - rust_area) / rust_area}")
|
||||
if abs(fraction - float(row["footprint_fraction"])) > FRACTION_TOLERANCE:
|
||||
failures.append(
|
||||
f"{name}: fraction differs by "
|
||||
f"{abs(fraction - float(row['footprint_fraction']))}"
|
||||
)
|
||||
|
||||
print(f"cases compared: {compared}")
|
||||
print(f"worst nadir: {worst['nadir']:.6e} rad")
|
||||
print(f"worst central: {worst['central']:.6e} rad")
|
||||
print(f"worst range: {worst['range']:.6e} km")
|
||||
print(f"worst area (rel): {worst['area']:.6e}")
|
||||
print(f"worst fraction: {worst['fraction']:.6e}")
|
||||
return (not failures, failures)
|
||||
|
||||
|
||||
def check_inverse(rows: list[dict[str, str]]) -> tuple[bool, list[str]]:
|
||||
"""Checks that the central angle and elevation relations invert."""
|
||||
failures: list[str] = []
|
||||
compared = 0
|
||||
worst = 0.0
|
||||
for row in rows:
|
||||
if not row["elevation_rad"]:
|
||||
continue
|
||||
central_degrees = float(row["inverse_central_degrees"])
|
||||
elevation = float(row["elevation_rad"])
|
||||
round_trip = float(row["round_trip_central_degrees"])
|
||||
compared += 1
|
||||
|
||||
# Recompute the round trip independently: from the elevation, the
|
||||
# central angle follows from the angle sum.
|
||||
satellite_radius = 6_378.137 + 550.0
|
||||
body_radius = 6_378.137
|
||||
nadir = reference_nadir(satellite_radius, body_radius, elevation)
|
||||
expected = max(0.0, math.pi / 2.0 - elevation - nadir)
|
||||
error = abs(expected * 180.0 / math.pi - round_trip)
|
||||
worst = max(worst, error)
|
||||
if error > 1e-9:
|
||||
failures.append(
|
||||
f"central {central_degrees} deg: round trip differs by {error}"
|
||||
)
|
||||
|
||||
print(f"inverse compared: {compared}")
|
||||
print(f"worst round trip: {worst:.6e} deg")
|
||||
return (not failures, failures)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
path = Path(argv[1]) if len(argv) > 1 else DEFAULT_INPUT
|
||||
if not path.exists():
|
||||
print(f"missing input file: {path}", file=sys.stderr)
|
||||
print(
|
||||
"generate it with: cargo run -p apsis-rs --features network "
|
||||
"--example dump_coverage > " + str(path),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
blocks = read_blocks(path)
|
||||
if len(blocks) < 2:
|
||||
print("input file does not have the two expected blocks", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
cases = list(csv.DictReader(blocks[0]))
|
||||
inverse = list(csv.DictReader(blocks[1]))
|
||||
if not cases or not inverse:
|
||||
print("input file has an empty block", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
cases_ok, case_failures = check_cases(cases)
|
||||
inverse_ok, inverse_failures = check_inverse(inverse)
|
||||
|
||||
for failure in case_failures + inverse_failures:
|
||||
print(f" {failure}")
|
||||
|
||||
if cases_ok and inverse_ok:
|
||||
print("result: pass")
|
||||
return 0
|
||||
print("result: FAIL")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
Loading…
Reference in New Issue