Files
astrokit/tests/common/mod.rs
T
2026-10-02 20:46:01 -04:00

164 lines
6.7 KiB
Rust

//! The reference-trajectory format, and reading it.
//!
//! A fixture is one propagated arc from another tool, with enough of that tool's setup
//! recorded alongside it to tell whether the comparison is fair. The format is this
//! crate's to define — a generator conforms to it, not the other way round — so it is
//! specified here and the loader is the specification's executable half.
//!
//! # Matching is the whole difficulty
//!
//! A disagreement between two propagators is only evidence of anything if they were asked
//! the same question. Every one of these has to agree before a number means anything, and
//! each has bitten someone:
//!
//! - **The gravity field, by name and truncation.** Not "EGM96 to degree 70" but the same
//! coefficient file: this crate found a 180x180 table that was not EGM96 and differed in
//! the fourth digit, which is 1.6 m/day of along-track at a low altitude.
//! - **The tide convention.** Tide-free against zero-tide moves `C(2,0)` alone, and that
//! term dominates everything.
//! - **The reference radius and `mu` the coefficients are defined against**, which are not
//! the body's WGS84 figures. This crate uses EGM96's own 6378136.3 m.
//! - **The third-body ephemeris**, by source and not only by body. DE440 against DE430 is
//! a real difference at the meter level over days.
//! - **Earth orientation.** Whether EOP corrections are applied at all, and from which
//! series, changes where the body-fixed frame points, which changes the gravity
//! gradient and the drag wind.
//! - **The atmosphere**, including which solar and geomagnetic drivers were read, since a
//! density model given different weather is a different model.
//! - **The drag and radiation coefficients and areas**, which tools variously take as
//! `Cd` and area separately or as the product.
//! - **The integrator and its tolerances**, since a comparison at 1 m has to be made
//! between two arcs each converged well inside that.
//!
//! The configuration block below is deliberately free-form text per entry rather than a
//! typed mirror of this crate's `Dynamics`: it records what the *other* tool was told, in
//! that tool's own vocabulary, so that a mismatch is visible rather than silently
//! translated away.
use serde::{Deserialize, Serialize};
/// One state along a reference arc.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct ReferenceState {
/// Seconds of uniform time after the arc's epoch.
pub elapsed: f64,
/// Position in the arc's frame, m.
pub position: [f64; 3],
/// Velocity in the arc's frame, m/s.
pub velocity: [f64; 3],
}
/// What produced a reference arc, in the producing tool's own terms.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ReferenceSetup {
/// The tool, for example `orekit`.
pub tool: String,
/// Its version, exactly as the tool reports it.
pub version: String,
/// The date the fixture was generated, `YYYY-MM-DD`.
pub generated: String,
/// The force model as the tool was configured, one entry per force, in that tool's
/// vocabulary rather than translated into this crate's.
pub forces: Vec<String>,
/// The integrator and its tolerances, likewise verbatim.
pub integrator: String,
/// Anything else that had to be matched: Earth orientation, tide convention,
/// ephemeris source, and so on.
pub notes: Vec<String>,
}
/// One reference arc.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ReferenceArc {
/// A short name, which is also the fixture's file stem.
pub name: String,
/// What produced it.
pub setup: ReferenceSetup,
/// Epoch of the first state, ISO 8601 with its time scale.
pub epoch: String,
/// The frame every state is expressed in, for example `EME2000`.
pub frame: String,
/// The states, earliest first, the first being the initial condition.
pub states: Vec<ReferenceState>,
}
/// Why a fixture could not be used.
#[derive(Debug)]
pub enum FixtureError {
/// The file could not be read.
Unreadable(std::io::Error),
/// The file is not the format.
Malformed(serde_json::Error),
/// The file parses but says something impossible.
Invalid(String),
}
impl std::fmt::Display for FixtureError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unreadable(error) => write!(f, "could not read the fixture: {error}"),
Self::Malformed(error) => write!(f, "not a reference arc: {error}"),
Self::Invalid(why) => write!(f, "unusable reference arc: {why}"),
}
}
}
impl ReferenceArc {
/// Reads an arc, checking it says something usable.
///
/// # Errors
///
/// If the file is missing, is not the format, or is internally inconsistent.
pub fn load(path: &std::path::Path) -> Result<Self, FixtureError> {
let text = std::fs::read_to_string(path).map_err(FixtureError::Unreadable)?;
let arc: Self = serde_json::from_str(&text).map_err(FixtureError::Malformed)?;
arc.check()?;
Ok(arc)
}
/// The checks a fixture has to pass before it is worth comparing against.
///
/// Not style: each of these would otherwise show up as a physics disagreement much
/// later. An arc whose times run backwards, or which records no setup, produces a
/// number that looks like a result.
fn check(&self) -> Result<(), FixtureError> {
let invalid = |why: String| Err(FixtureError::Invalid(why));
if self.states.len() < 2 {
return invalid(format!("{}: {} states", self.name, self.states.len()));
}
if self.states[0].elapsed != 0.0 {
return invalid(format!(
"{}: starts at {} s rather than at its own epoch",
self.name, self.states[0].elapsed
));
}
if self.setup.forces.is_empty() {
return invalid(format!("{}: records no force model", self.name));
}
for pair in self.states.windows(2) {
if pair[1].elapsed <= pair[0].elapsed {
return invalid(format!(
"{}: times are not increasing, {} then {}",
self.name, pair[0].elapsed, pair[1].elapsed
));
}
}
for state in &self.states {
let finite = state
.position
.iter()
.chain(&state.velocity)
.all(|x| x.is_finite());
if !finite {
return invalid(format!("{}: a state is not finite", self.name));
}
}
Ok(())
}
/// How long the arc covers, in seconds.
pub fn duration(&self) -> f64 {
self.states.last().map(|s| s.elapsed).unwrap_or(0.0)
}
}