Compare commits

..

No commits in common. "71b006bcb3588051092fa6657a4223c475365c19" and "2acc95ff21388682dbff66899a365ec2a1dc2a84" have entirely different histories.

60 changed files with 106 additions and 752 deletions

View File

@ -18,13 +18,11 @@ jobs:
- name: Format - name: Format
run: cargo fmt --all --check run: cargo fmt --all --check
- name: Check - name: Check
# --all-features matters: the facade crate enables nothing by default, run: cargo check --workspace --all-targets
# so without it almost all of its code, and every example, is skipped.
run: cargo check --workspace --all-targets --all-features
- name: Clippy - name: Clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings run: cargo clippy --workspace --all-targets -- -D warnings
- name: Test - name: Test
run: cargo test --workspace --all-features run: cargo test --workspace
- name: Integration checks - name: Integration checks
# The cross-layer checks are examples rather than tests, because they # 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 # 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 # meaningful on a quiet machine, but it must still compile so that it
# does not rot. # does not rot.
run: cargo build -p apsis-rs --features full --example bench 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 - 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

45
Cargo.lock generated
View File

@ -6,6 +6,7 @@ version = 4
name = "apsis-access" name = "apsis-access"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-coverage", "apsis-coverage",
"apsis-geodesy", "apsis-geodesy",
"apsis-math", "apsis-math",
@ -15,6 +16,7 @@ dependencies = [
name = "apsis-antenna" name = "apsis-antenna"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-rf", "apsis-rf",
] ]
@ -22,6 +24,7 @@ dependencies = [
name = "apsis-atmosphere" name = "apsis-atmosphere"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-rf", "apsis-rf",
] ]
@ -29,6 +32,7 @@ dependencies = [
name = "apsis-conflict" name = "apsis-conflict"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-resource", "apsis-resource",
"apsis-schedule", "apsis-schedule",
"apsis-task", "apsis-task",
@ -38,7 +42,9 @@ dependencies = [
name = "apsis-constellation" name = "apsis-constellation"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-kepler", "apsis-kepler",
"apsis-math",
"apsis-sgp4", "apsis-sgp4",
] ]
@ -46,6 +52,7 @@ dependencies = [
name = "apsis-coord" name = "apsis-coord"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-frames", "apsis-frames",
"apsis-geodesy", "apsis-geodesy",
"apsis-math", "apsis-math",
@ -59,6 +66,7 @@ version = "0.1.0"
name = "apsis-coverage" name = "apsis-coverage"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-geodesy", "apsis-geodesy",
"apsis-math", "apsis-math",
] ]
@ -75,6 +83,7 @@ dependencies = [
name = "apsis-ephemeris" name = "apsis-ephemeris"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-kepler", "apsis-kepler",
"apsis-math", "apsis-math",
] ]
@ -82,11 +91,15 @@ dependencies = [
[[package]] [[package]]
name = "apsis-event" name = "apsis-event"
version = "0.1.0" version = "0.1.0"
dependencies = [
"apsis-core",
]
[[package]] [[package]]
name = "apsis-frames" name = "apsis-frames"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-math", "apsis-math",
] ]
@ -94,12 +107,17 @@ dependencies = [
name = "apsis-geodesy" name = "apsis-geodesy"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-math", "apsis-math",
] ]
[[package]] [[package]]
name = "apsis-handover" name = "apsis-handover"
version = "0.1.0" version = "0.1.0"
dependencies = [
"apsis-core",
"apsis-math",
]
[[package]] [[package]]
name = "apsis-interference" name = "apsis-interference"
@ -113,6 +131,7 @@ dependencies = [
name = "apsis-isl" name = "apsis-isl"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-math", "apsis-math",
] ]
@ -120,7 +139,10 @@ dependencies = [
name = "apsis-kepler" name = "apsis-kepler"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-math", "apsis-math",
"apsis-time",
"apsis-tle",
] ]
[[package]] [[package]]
@ -145,6 +167,7 @@ version = "0.1.0"
name = "apsis-maneuver" name = "apsis-maneuver"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-kepler", "apsis-kepler",
"apsis-math", "apsis-math",
] ]
@ -157,6 +180,8 @@ version = "0.1.0"
name = "apsis-mesh" name = "apsis-mesh"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-math",
"apsis-topology", "apsis-topology",
] ]
@ -172,21 +197,29 @@ dependencies = [
name = "apsis-orbit" name = "apsis-orbit"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-ephemeris", "apsis-ephemeris",
"apsis-kepler", "apsis-kepler",
"apsis-maneuver", "apsis-maneuver",
"apsis-math", "apsis-math",
"apsis-sgp4", "apsis-sgp4",
"apsis-time",
"apsis-tle", "apsis-tle",
] ]
[[package]] [[package]]
name = "apsis-output" name = "apsis-output"
version = "0.1.0" version = "0.1.0"
dependencies = [
"apsis-core",
]
[[package]] [[package]]
name = "apsis-resource" name = "apsis-resource"
version = "0.1.0" version = "0.1.0"
dependencies = [
"apsis-core",
]
[[package]] [[package]]
name = "apsis-rf" name = "apsis-rf"
@ -199,6 +232,8 @@ dependencies = [
name = "apsis-routing" name = "apsis-routing"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-math",
"apsis-topology", "apsis-topology",
] ]
@ -252,6 +287,7 @@ dependencies = [
name = "apsis-scenario" name = "apsis-scenario"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-resource", "apsis-resource",
"apsis-task", "apsis-task",
] ]
@ -260,6 +296,7 @@ dependencies = [
name = "apsis-schedule" name = "apsis-schedule"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-resource", "apsis-resource",
"apsis-task", "apsis-task",
] ]
@ -269,6 +306,7 @@ name = "apsis-scheduler"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-conflict", "apsis-conflict",
"apsis-core",
"apsis-resource", "apsis-resource",
"apsis-schedule", "apsis-schedule",
"apsis-task", "apsis-task",
@ -278,6 +316,7 @@ dependencies = [
name = "apsis-sgp4" name = "apsis-sgp4"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-math", "apsis-math",
"apsis-time", "apsis-time",
"apsis-tle", "apsis-tle",
@ -287,6 +326,7 @@ dependencies = [
name = "apsis-sim" name = "apsis-sim"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-event", "apsis-event",
"apsis-output", "apsis-output",
"apsis-resource", "apsis-resource",
@ -301,6 +341,7 @@ dependencies = [
name = "apsis-sim-engine" name = "apsis-sim-engine"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-event", "apsis-event",
] ]
@ -308,6 +349,7 @@ dependencies = [
name = "apsis-task" name = "apsis-task"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-resource", "apsis-resource",
] ]
@ -315,6 +357,7 @@ dependencies = [
name = "apsis-time" name = "apsis-time"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-leap", "apsis-leap",
] ]
@ -322,6 +365,7 @@ dependencies = [
name = "apsis-tle" name = "apsis-tle"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-time", "apsis-time",
] ]
@ -329,6 +373,7 @@ dependencies = [
name = "apsis-topology" name = "apsis-topology"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core",
"apsis-math", "apsis-math",
] ]

View File

@ -17,15 +17,9 @@ foundation -> physics -> orbit -> rf -> network -> ops -> sim -> apsis-rs
约束: 约束:
- 依赖只能从左向右。下层可依赖其上任意层,上层不得依赖其下任意层。 - 依赖只能从左向右,禁止反向依赖
例如 `network` 可以依赖 `orbit`(向左),但 `orbit` 不得依赖 `network` - 禁止跨层跳跃,例如 `orbit` 不得直接依赖 `network`
(向右,即反向)。左向依赖不限层数,允许跨层,因为把 `orbit` 的值 - 层内允许依赖该层的基准 crate:`foundation` 内 `apsis-core` 为基准,其余 crate 可依赖它;其余层内部以各自的门面 crate(`apsis-orbit`、`apsis-rf`、`apsis-sim` 等)作为整合点
传到 `network` 时中间层未必提供中转类型。
- `foundation` 的 `apsis-core` 是全局基准,任何层任何 crate 均可依赖它。
`apsis-math` 同理,因为线性代数被各层共用。
- 层内以该层的门面 crate(`apsis-orbit`、`apsis-rf`、`apsis-sim`)作为整合点。
这套规则由 `scripts/check-layers.sh` 强制,CI 会运行它。
各层内容: 各层内容:
@ -76,7 +70,6 @@ apsis-rs/
│ └── apsis-rs/ │ └── apsis-rs/
│ ├── examples/ # 对拍导出与跨层集成检查 │ ├── examples/ # 对拍导出与跨层集成检查
│ └── ... │ └── ...
├── scripts/ # 仓库检查脚本
├── validation/ # Python 对拍验证 ├── validation/ # Python 对拍验证
└── docs/design/ # 设计文档 └── docs/design/ # 设计文档
``` ```

View File

@ -205,7 +205,3 @@ required-features = ["sim"]
[[example]] [[example]]
name = "bench" name = "bench"
required-features = ["full"] required-features = ["full"]
[[example]]
name = "dump_coverage"
required-features = ["network"]

View File

@ -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);
}
}

View File

@ -41,9 +41,7 @@ use apsis_tle::TwoLineElement;
use apsis_topology::{Topology, TopologySample, TopologySeries}; use apsis_topology::{Topology, TopologySample, TopologySeries};
/// The Earth equatorial radius, in kilometers, used for the link geometry. /// The Earth equatorial radius, in kilometers, used for the link geometry.
/// const BODY_RADIUS_KM: f64 = 6_378.137;
/// The value comes from the shared constants rather than being repeated.
const BODY_RADIUS_KM: f64 = apsis_core::constant::EARTH_EQUATORIAL_RADIUS;
/// The element set of a satellite in a 550 km orbit, used as the source of /// The element set of a satellite in a 550 km orbit, used as the source of
/// the propagated track. /// the propagated track.

View File

@ -1,21 +1,13 @@
//! Shared foundation types for the ApsisRS workspace: a general purpose //! Shared foundation types for the ApsisRS workspace: error handling,
//! error type, result aliases, physical constants, and the unit marker //! result aliases, physical constants, and the unit marker trait.
//! trait.
//! //!
//! This crate sits at the bottom of the dependency graph. It has no //! 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. //! dependency on any other ApsisRS crate, so every other crate may use it.
//! //!
//! Three concerns live here: //! Three concerns live here:
//! //!
//! - [`Error`] and [`Result`] are a general purpose error carrying a coarse //! - [`Error`] and [`Result`] give the workspace a single error surface
//! [`ErrorKind`]. They exist so that a function whose failure has no //! without pulling in an external error handling crate.
//! 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.
//! - [`constant`] holds physical and astronomical constants expressed in //! - [`constant`] holds physical and astronomical constants expressed in
//! SI units. //! SI units.
//! - [`Quantity`] marks newtypes that carry a physical unit, so generic //! - [`Quantity`] marks newtypes that carry a physical unit, so generic

View File

@ -3,7 +3,7 @@
//! Each quantity is a newtype over `f64` that carries its unit in the type //! 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 //! system, so a length cannot be passed where a duration is expected. Units
//! of the same dimension convert through //! 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. //! offers direct conversions for the common cases.
//! //!
//! A newtype implements both [`Unit`] and [`Quantity`], with its own type as //! A newtype implements both [`Unit`] and [`Quantity`], with its own type as

View File

@ -11,6 +11,7 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true apsis-math.workspace = true
apsis-geodesy.workspace = true apsis-geodesy.workspace = true
apsis-coverage.workspace = true apsis-coverage.workspace = true

View File

@ -11,5 +11,7 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true
apsis-kepler.workspace = true apsis-kepler.workspace = true
apsis-sgp4.workspace = true apsis-sgp4.workspace = true

View File

@ -11,5 +11,6 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true apsis-math.workspace = true
apsis-geodesy.workspace = true apsis-geodesy.workspace = true

View File

@ -11,3 +11,5 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true

View File

@ -11,4 +11,5 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true apsis-math.workspace = true

View File

@ -11,4 +11,6 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true
apsis-topology.workspace = true apsis-topology.workspace = true

View File

@ -11,4 +11,6 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true
apsis-topology.workspace = true apsis-topology.workspace = true

View File

@ -2,7 +2,7 @@
//! //!
//! The topology of a satellite network changes as the satellites move, so a //! 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 //! 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. //! route together with the time it remains valid.
//! //!
//! Two forms of routing are provided: //! Two forms of routing are provided:

View File

@ -11,4 +11,5 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true apsis-math.workspace = true

View File

@ -11,6 +11,7 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-resource.workspace = true apsis-resource.workspace = true
apsis-task.workspace = true apsis-task.workspace = true
apsis-schedule.workspace = true apsis-schedule.workspace = true

View File

@ -11,3 +11,4 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true

View File

@ -11,5 +11,6 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-resource.workspace = true apsis-resource.workspace = true
apsis-task.workspace = true apsis-task.workspace = true

View File

@ -11,6 +11,7 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-resource.workspace = true apsis-resource.workspace = true
apsis-task.workspace = true apsis-task.workspace = true
apsis-schedule.workspace = true apsis-schedule.workspace = true

View File

@ -11,4 +11,5 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-resource.workspace = true apsis-resource.workspace = true

View File

@ -11,6 +11,7 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true apsis-math.workspace = true
[dev-dependencies] [dev-dependencies]

View File

@ -13,7 +13,7 @@
//! two-body theory, or read from a file all work the same way. //! 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 //! - [`Sampler`] fills a table by calling a function that produces a state
//! from a time. In this workspace that function is normally //! 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. //! a closure, so a test can use an analytic function.
//! //!
//! Interpolation is Hermite cubic, which uses both the position and the //! Interpolation is Hermite cubic, which uses both the position and the

View File

@ -94,7 +94,7 @@ impl Sampler {
/// ///
/// The function maps a time in seconds to a position and velocity. In /// The function maps a time in seconds to a position and velocity. In
/// this workspace it is normally a closure over /// 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. /// the sampler can be tested against an analytic orbit.
/// ///
/// Returns an error when the sampler is not usable or when the function /// Returns an error when the sampler is not usable or when the function

View File

@ -11,4 +11,7 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true apsis-math.workspace = true
apsis-time.workspace = true
apsis-tle.workspace = true

View File

@ -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 /// by Newton's method. The starting guess is the mean anomaly plus a term
/// proportional to the eccentricity, which keeps the iteration stable for /// proportional to the eccentricity, which keeps the iteration stable for
/// eccentricities approaching one. The iteration always terminates, and the /// 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 { pub fn eccentric_from_mean(mean_anomaly: f64, eccentricity: f64) -> f64 {
let mean = wrap_to_pi(mean_anomaly); let mean = wrap_to_pi(mean_anomaly);
// A first order correction to the circular guess. // A first order correction to the circular guess.

View File

@ -11,5 +11,6 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true apsis-math.workspace = true
apsis-kepler.workspace = true apsis-kepler.workspace = true

View File

@ -11,7 +11,9 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true apsis-math.workspace = true
apsis-time.workspace = true
apsis-tle.workspace = true apsis-tle.workspace = true
apsis-sgp4.workspace = true apsis-sgp4.workspace = true
apsis-kepler.workspace = true apsis-kepler.workspace = true

View File

@ -222,7 +222,6 @@ impl Orbit {
/// A failure while sampling an orbit into an ephemeris. /// A failure while sampling an orbit into an ephemeris.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum EphemerisSamplingError { pub enum EphemerisSamplingError {
/// The requested grid is not usable: the step is not positive or the /// The requested grid is not usable: the step is not positive or the
/// range is empty or reversed. /// range is empty or reversed.
@ -248,7 +247,6 @@ impl core::error::Error for EphemerisSamplingError {}
/// A failure while applying a burn to an orbit. /// A failure while applying a burn to an orbit.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ManeuverApplicationError { pub enum ManeuverApplicationError {
/// The state before the burn could not be propagated. /// The state before the burn could not be propagated.
Propagation(PropagationError), Propagation(PropagationError),

View File

@ -11,6 +11,7 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true apsis-math.workspace = true
apsis-tle.workspace = true apsis-tle.workspace = true
apsis-time.workspace = true apsis-time.workspace = true

View File

@ -11,4 +11,5 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-time.workspace = true apsis-time.workspace = true

View File

@ -11,6 +11,7 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true apsis-math.workspace = true
apsis-geodesy.workspace = true apsis-geodesy.workspace = true
apsis-frames.workspace = true apsis-frames.workspace = true

View File

@ -11,4 +11,5 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true apsis-math.workspace = true

View File

@ -165,7 +165,7 @@ pub(crate) fn rotation_x(angle: f64) -> Mat3 {
/// Returns a rotation of `angle` about the second axis, in radians. /// 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 { fn rotation_y(angle: f64) -> Mat3 {
let (sine, cosine) = angle.sin_cos(); let (sine, cosine) = angle.sin_cos();
Mat3::from_rows([cosine, 0.0, -sine], [0.0, 1.0, 0.0], [sine, 0.0, cosine]) 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. /// 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 { pub fn rotation_z(angle: f64) -> Mat3 {
let (sine, cosine) = angle.sin_cos(); let (sine, cosine) = angle.sin_cos();
Mat3::from_rows([cosine, sine, 0.0], [-sine, cosine, 0.0], [0.0, 0.0, 1.0]) Mat3::from_rows([cosine, sine, 0.0], [-sine, cosine, 0.0], [0.0, 0.0, 1.0])

View File

@ -11,4 +11,5 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true apsis-math.workspace = true

View File

@ -6,7 +6,7 @@
//! differ by up to about 0.19 degrees at mid latitudes, so the distinction //! differ by up to about 0.19 degrees at mid latitudes, so the distinction
//! matters for pointing and ranging. //! 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 //! Its defining parameters are the semi-major axis and the inverse
//! flattening; every other quantity is derived from those two, so the //! flattening; every other quantity is derived from those two, so the
//! derived values agree with the ones published in the standard to the //! derived values agree with the ones published in the standard to the

View File

@ -11,4 +11,5 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-leap.workspace = true apsis-leap.workspace = true

View File

@ -11,4 +11,5 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-rf.workspace = true apsis-rf.workspace = true

View File

@ -17,7 +17,7 @@
//! uniformly illuminated circular aperture it is about minus 17.6 dB. //! uniformly illuminated circular aperture it is about minus 17.6 dB.
//! //!
//! Every function here takes or returns angles in radians and gains as //! 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: //! Sources:
//! //!

View File

@ -11,4 +11,5 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-rf.workspace = true apsis-rf.workspace = true

View File

@ -11,3 +11,4 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true

View File

@ -11,3 +11,4 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true

View File

@ -11,5 +11,6 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-resource.workspace = true apsis-resource.workspace = true
apsis-task.workspace = true apsis-task.workspace = true

View File

@ -11,4 +11,5 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-event.workspace = true apsis-event.workspace = true

View File

@ -13,7 +13,7 @@
//! implementation meaningful. //! implementation meaningful.
//! //!
//! The engine stops for one of four reasons, reported as a //! 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 //! 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 //! 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. //! model that schedules events forever cannot run without bound.

View File

@ -11,6 +11,7 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-resource.workspace = true apsis-resource.workspace = true
apsis-task.workspace = true apsis-task.workspace = true
apsis-schedule.workspace = true apsis-schedule.workspace = true

View File

@ -8,8 +8,6 @@
## 跨层约定 ## 跨层约定
- [单位与坐标约定](units.md) - [单位与坐标约定](units.md)
- [错误与容差策略](errors.md)
- [分层边界](layers.md)
## 各层设计 ## 各层设计

View File

@ -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` 漏进库代码
(工作区约定库代码不使用它们)。

View File

@ -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 条边,全部方向正确。

View File

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

View File

@ -32,8 +32,9 @@ validation/
│ ├── time/ # 时间尺度、儒略日对拍 │ ├── time/ # 时间尺度、儒略日对拍
│ ├── coord/ # 坐标变换对拍 │ ├── coord/ # 坐标变换对拍
│ ├── link/ # 链路预算对拍 │ ├── link/ # 链路预算对拍
│ └── constellation/ # 覆盖几何对拍 │ └── constellation/ # 星座覆盖对拍
└── data/ └── data/
├── tle/ # 公开 TLE 样本
└── expected/ # Rust 侧导出的待比对数据 └── expected/ # Rust 侧导出的待比对数据
``` ```
@ -89,10 +90,6 @@ cargo run -p apsis-rs --features physics --example dump_iau2000 \
# TEME → 地固系(SGP4 输出链路) # TEME → 地固系(SGP4 输出链路)
cargo run -p apsis-rs --features physics --example dump_teme \ cargo run -p apsis-rs --features physics --example dump_teme \
> validation/data/expected/rust_teme.csv > validation/data/expected/rust_teme.csv
# 覆盖几何(仰角、星下点角、地心角、斜距、覆盖面积)
cargo run -p apsis-rs --features network --example dump_coverage \
> validation/data/expected/rust_coverage.csv
``` ```
Python 侧读取同一批输入,用参考实现重算并输出误差表: 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/atmosphere/check_gas_exact.py
pixi run python validation/python/time/check_iau2000.py pixi run python validation/python/time/check_iau2000.py
pixi run python validation/python/coord/check_teme.py pixi run python validation/python/coord/check_teme.py
pixi run python validation/python/constellation/check_coverage.py
``` ```
两个脚本都以退出码 0 表示通过、1 表示超差、2 表示缺少输入文件。 两个脚本都以退出码 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 km/s |
| 时间 | < 1e-6 s | | 时间 | < 1e-6 s |
| 角度(覆盖几何) | 1e-12 rad |
| 面积、覆盖占比 | 相对 1e-12 |
| 链路预算、衰减 | 1e-6 至 1e-9 dB,视模型而定 |
| 地球定向矩阵 | 1e-9 |
各脚本把容差写成其源文件顶部的具名常量,值按所比较的物理量设定,见
`docs/design/errors.md` 的"对拍容差"一节。
超差时修正 Rust 侧算法,不放宽容差,除非经确认。 超差时修正 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(相对) | | `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 | | `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 | | `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 |
### 关于射频层对拍的说明 ### 关于射频层对拍的说明

View File

View File

View File

View File

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

View File

View File

View File

View File