use std::cell::Cell;
use thiserror::Error;
pub use trust_region_least_squares::loss::Loss;
use trust_region_least_squares::model::{solve_model, ResidualModel};
pub use trust_region_least_squares::trf::XScale;
use trust_region_least_squares::trf::{TrfError, TrfOptions, TrfResult};
use crate::astro::anomaly::true_to_mean;
use crate::astro::elements::{rv2coe, ClassicalElements, OrbitType};
use crate::astro::math::vec3;
use crate::astro::omm::Omm;
use crate::astro::sgp4::{ElementSet, Error as Sgp4Error, JulianDate, OpsMode, Satellite};
use crate::astro::time::civil::{civil_from_split_julian_date, day_of_year};
use crate::astro::tle::{self, TleElements};
use super::MAX_MINUTES_SINCE_EPOCH;
const BSTAR_SCALE: f64 = 1.0e-4;
const ECC_MAX: f64 = 0.999;
const PENALTY_KM: f64 = 1.0e6;
const SEED_REFINE_PASSES: usize = 2;
const BSTAR_OBSERVABLE_REL: f64 = 1.0e-5;
const MU_WGS72_KM3_S2: f64 = 398_600.8;
const SECONDS_PER_DAY: f64 = 86_400.0;
const TAU: f64 = std::f64::consts::TAU;
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FitSample {
pub epoch: JulianDate,
pub position_teme_km: [f64; 3],
pub velocity_teme_km_s: Option<[f64; 3]>,
}
#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub enum FitEpoch {
#[default]
Midpoint,
First,
Last,
Sample(usize),
Jd(JulianDate),
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct TleMetadata {
pub catalog_number: u32,
pub classification: String,
pub international_designator: String,
pub element_set_number: i32,
pub rev_at_epoch: i64,
pub object_name: String,
}
impl Default for TleMetadata {
fn default() -> Self {
Self {
catalog_number: 0,
classification: "U".to_string(),
international_designator: String::new(),
element_set_number: 999,
rev_at_epoch: 0,
object_name: String::new(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FitConfig {
pub epoch: FitEpoch,
pub fit_bstar: bool,
pub bstar_seed: f64,
pub use_velocity: bool,
pub velocity_weight_s: Option<f64>,
pub weights: Option<Vec<f64>>,
pub opsmode: OpsMode,
pub ftol: Option<f64>,
pub xtol: Option<f64>,
pub gtol: Option<f64>,
pub max_nfev: Option<usize>,
pub x_scale: Option<XScale>,
pub loss: Loss,
pub f_scale: f64,
pub metadata: TleMetadata,
}
impl Default for FitConfig {
fn default() -> Self {
Self {
epoch: FitEpoch::default(),
fit_bstar: true,
bstar_seed: 0.0,
use_velocity: true,
velocity_weight_s: None,
weights: None,
opsmode: OpsMode::Improved,
ftol: None,
xtol: None,
gtol: None,
max_nfev: None,
x_scale: None,
loss: Loss::Linear,
f_scale: 1.0,
metadata: TleMetadata::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FitStatistics {
pub rms_position_km: f64,
pub max_position_km: f64,
pub rms_position_axes_km: [f64; 3],
pub rms_velocity_km_s: Option<f64>,
pub tle_rms_position_km: f64,
pub status: i32,
pub nfev: usize,
pub njev: usize,
pub cost: f64,
pub optimality: f64,
pub bstar_observable: bool,
pub seed_refine_passes: usize,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct TleFit {
pub elements: ElementSet,
pub line1: String,
pub line2: String,
pub omm: Omm,
pub stats: FitStatistics,
}
#[derive(Debug, Clone, PartialEq, Error)]
pub enum TleFitError {
#[error("fit arc has {samples} samples; need at least {needed}")]
ArcTooShort { samples: usize, needed: usize },
#[error("fit input invalid: {field}: {reason}")]
InvalidInput {
field: &'static str,
reason: &'static str,
},
#[error("sample epochs must be strictly increasing (violation at index {index})")]
EpochsNotIncreasing { index: usize },
#[error("requested epoch lies outside the sample arc")]
EpochOutsideArc,
#[error("velocities must be present on every sample or on none")]
MixedVelocityPresence,
#[error("target arc is not elliptical (ecc >= 1 at the seed epoch)")]
NotElliptical,
#[error("seed inclination {inclination_deg} deg is too close to retrograde-equatorial")]
InclinationNearRetrograde { inclination_deg: f64 },
#[error("seed elements cannot propagate the arc at sample {epoch_index}: {source}")]
SeedPropagation {
epoch_index: usize,
source: Sgp4Error,
},
#[error("solver error: {0}")]
Solver(TrfError),
#[error("solver stopped at an infeasible point")]
SolutionInfeasible,
#[error("evaluation budget exhausted before convergence (best-effort result attached)")]
DidNotConverge { result: Box<TleFit> },
#[error("fitted elements failed final validation: {0}")]
FinalElements(Sgp4Error),
}
pub fn fit_tle(samples: &[FitSample], config: &FitConfig) -> Result<TleFit, TleFitError> {
let resolved = validate_and_resolve(samples, config)?;
let (x0, seed_refine_passes) = initial_guess(samples, config, &resolved)?;
let seed_elements = chart_to_elements(
&x0,
resolved.epoch,
config.fit_bstar,
config.bstar_seed,
config.metadata.catalog_number,
)
.ok_or(TleFitError::NotElliptical)?;
validate_seed_propagates(samples, &seed_elements, config.opsmode)?;
let w_vel = config.velocity_weight_s.unwrap_or_else(|| {
let n_rad_s = x0[0] * TAU / SECONDS_PER_DAY;
1.0 / n_rad_s
});
let problem = Sgp4FitProblem {
samples,
epoch: resolved.epoch,
opsmode: config.opsmode,
fit_bstar: config.fit_bstar,
fixed_bstar: config.bstar_seed,
catalog_number: config.metadata.catalog_number,
weights: resolved.weights,
w_vel,
use_velocity: resolved.use_velocity,
penalty_hit_at_solution: Cell::new(false),
};
let options = solver_options(config);
let result = solve_model(&problem, &x0, &options).map_err(TleFitError::Solver)?;
let mut final_rows = Vec::new();
problem.penalty_hit_at_solution.set(false);
problem.residual(&result.x, &mut final_rows);
if problem.penalty_hit_at_solution.get() {
return Err(TleFitError::SolutionInfeasible);
}
let fit = build_fit(&problem, config, result, seed_refine_passes)?;
if fit.stats.status == 0 {
Err(TleFitError::DidNotConverge {
result: Box::new(fit),
})
} else {
Ok(fit)
}
}
struct ResolvedFit {
epoch: JulianDate,
epoch_index: usize,
use_velocity: bool,
weights: Vec<f64>,
}
struct Sgp4FitProblem<'a> {
samples: &'a [FitSample],
epoch: JulianDate,
opsmode: OpsMode,
fit_bstar: bool,
fixed_bstar: f64,
catalog_number: u32,
weights: Vec<f64>,
w_vel: f64,
use_velocity: bool,
penalty_hit_at_solution: Cell<bool>,
}
impl Sgp4FitProblem<'_> {
fn residual_len(&self) -> usize {
self.samples.len() * 3 * if self.use_velocity { 2 } else { 1 }
}
fn penalty(&self, out: &mut Vec<f64>) {
self.penalty_hit_at_solution.set(true);
out.clear();
out.resize(self.residual_len(), PENALTY_KM);
}
}
impl ResidualModel for Sgp4FitProblem<'_> {
fn residual(&self, x: &[f64], out: &mut Vec<f64>) {
let Some(elements) = chart_to_elements(
x,
self.epoch,
self.fit_bstar,
self.fixed_bstar,
self.catalog_number,
) else {
self.penalty(out);
return;
};
let Ok(satellite) = Satellite::from_elements_with_opsmode(&elements, self.opsmode) else {
self.penalty(out);
return;
};
out.clear();
for (sample, &weight) in self.samples.iter().zip(&self.weights) {
let Ok(prediction) = satellite.propagate_jd(sample.epoch) else {
self.penalty(out);
return;
};
for axis in 0..3 {
out.push(weight * (prediction.position[axis] - sample.position_teme_km[axis]));
}
if self.use_velocity {
let velocity = sample
.velocity_teme_km_s
.expect("validated velocity presence");
for (axis, target) in velocity.iter().enumerate() {
out.push(weight * self.w_vel * (prediction.velocity[axis] - *target));
}
}
}
self.penalty_hit_at_solution.set(false);
}
}
#[derive(Debug, Clone, Copy)]
struct MeanChart {
n_rev_day: f64,
af: f64,
ag: f64,
chi: f64,
psi: f64,
lam: f64,
}
impl MeanChart {
fn to_vec(self, fit_bstar: bool, bstar: f64) -> Vec<f64> {
let mut x = vec![
self.n_rev_day,
self.af,
self.ag,
self.chi,
self.psi,
self.lam,
];
if fit_bstar {
x.push(bstar / BSTAR_SCALE);
}
x
}
#[cfg(test)]
fn from_elements(elements: &ElementSet) -> Self {
let ecc = elements.eccentricity;
let argp = deg_to_rad(elements.argument_of_perigee_deg);
let raan = deg_to_rad(elements.right_ascension_deg);
let mean = deg_to_rad(elements.mean_anomaly_deg);
let incl = deg_to_rad(elements.inclination_deg);
let varpi = argp + raan;
let half_tan = (incl * 0.5).tan();
Self {
n_rev_day: elements.mean_motion_rev_per_day,
af: ecc * varpi.cos(),
ag: ecc * varpi.sin(),
chi: half_tan * raan.sin(),
psi: half_tan * raan.cos(),
lam: normalize_angle(mean + varpi),
}
}
}
fn validate_and_resolve(
samples: &[FitSample],
config: &FitConfig,
) -> Result<ResolvedFit, TleFitError> {
if samples.len() < 3 {
return Err(TleFitError::ArcTooShort {
samples: samples.len(),
needed: 3,
});
}
for (i, sample) in samples.iter().enumerate() {
validate_epoch(sample.epoch)?;
if i > 0 && seconds_between(sample.epoch, samples[i - 1].epoch) <= 0.0 {
return Err(TleFitError::EpochsNotIncreasing { index: i });
}
validate_vec3("position_teme_km", &sample.position_teme_km)?;
if vec3::norm3(sample.position_teme_km) <= 0.0 {
return Err(TleFitError::InvalidInput {
field: "position_teme_km",
reason: "magnitude must be positive",
});
}
if let Some(velocity) = &sample.velocity_teme_km_s {
validate_vec3("velocity_teme_km_s", velocity)?;
}
}
let velocity_count = samples
.iter()
.filter(|sample| sample.velocity_teme_km_s.is_some())
.count();
let use_velocity = if config.use_velocity {
if velocity_count == 0 {
false
} else if velocity_count == samples.len() {
true
} else {
return Err(TleFitError::MixedVelocityPresence);
}
} else {
false
};
let n_params = 6 + usize::from(config.fit_bstar);
let rows_per_sample = 3 * if use_velocity { 2 } else { 1 };
if samples.len() * rows_per_sample < n_params {
let needed = n_params.div_ceil(rows_per_sample).max(3);
return Err(TleFitError::ArcTooShort {
samples: samples.len(),
needed,
});
}
let weights = if let Some(weights) = &config.weights {
if weights.len() != samples.len() {
return Err(TleFitError::InvalidInput {
field: "weights",
reason: "length must match samples",
});
}
for &weight in weights {
validate_positive("weights", weight)?;
}
weights.clone()
} else {
vec![1.0; samples.len()]
};
validate_optional_positive("velocity_weight_s", config.velocity_weight_s)?;
validate_finite("bstar_seed", config.bstar_seed)?;
validate_optional_positive("ftol", config.ftol)?;
validate_optional_positive("xtol", config.xtol)?;
validate_optional_positive("gtol", config.gtol)?;
validate_positive("f_scale", config.f_scale)?;
if config.max_nfev == Some(0) {
return Err(TleFitError::InvalidInput {
field: "max_nfev",
reason: "must be positive",
});
}
if config.metadata.catalog_number > 99_999 {
return Err(TleFitError::InvalidInput {
field: "metadata.catalog_number",
reason: "must be <= 99999",
});
}
if !matches!(config.metadata.classification.as_str(), "U" | "C" | "S") {
return Err(TleFitError::InvalidInput {
field: "metadata.classification",
reason: "must be U, C, or S",
});
}
if i32::try_from(config.metadata.rev_at_epoch).is_err() {
return Err(TleFitError::InvalidInput {
field: "metadata.rev_at_epoch",
reason: "must fit i32",
});
}
if let Some(XScale::Values(values)) = &config.x_scale {
if values.len() != n_params {
return Err(TleFitError::InvalidInput {
field: "x_scale",
reason: "length must match parameter count",
});
}
for &value in values {
validate_positive("x_scale", value)?;
}
}
let (epoch, epoch_index) = resolve_epoch(samples, config.epoch)?;
for sample in samples {
let minutes = seconds_between(sample.epoch, epoch).abs() / 60.0;
if minutes > MAX_MINUTES_SINCE_EPOCH {
return Err(TleFitError::InvalidInput {
field: "arc_span",
reason: "outside SGP4 time domain",
});
}
}
Ok(ResolvedFit {
epoch,
epoch_index,
use_velocity,
weights,
})
}
fn initial_guess(
samples: &[FitSample],
config: &FitConfig,
resolved: &ResolvedFit,
) -> Result<(Vec<f64>, usize), TleFitError> {
let sample = samples[resolved.epoch_index];
let velocity = sample
.velocity_teme_km_s
.unwrap_or_else(|| finite_difference_velocity(samples, resolved.epoch_index));
let mut target = chart_from_state(sample.position_teme_km, velocity)?;
let dt = seconds_between(resolved.epoch, sample.epoch);
target.lam = normalize_angle(target.lam + target.n_rev_day * TAU / SECONDS_PER_DAY * dt);
if rad_to_deg(2.0 * (target.chi.hypot(target.psi)).atan()) >= 179.5 {
return Err(TleFitError::InclinationNearRetrograde {
inclination_deg: rad_to_deg(2.0 * (target.chi.hypot(target.psi)).atan()),
});
}
let x0 = target.to_vec(config.fit_bstar, config.bstar_seed);
let (x, passes) = refine_seed(
x0,
target,
resolved.epoch,
config.opsmode,
config.fit_bstar,
config.bstar_seed,
config.metadata.catalog_number,
);
Ok((x, passes))
}
fn refine_seed(
mut x: Vec<f64>,
target: MeanChart,
epoch: JulianDate,
opsmode: OpsMode,
fit_bstar: bool,
fixed_bstar: f64,
catalog_number: u32,
) -> (Vec<f64>, usize) {
let mut passes = 0;
let mut previous_norm = f64::INFINITY;
for _ in 0..SEED_REFINE_PASSES {
let Some(elements) = chart_to_elements(&x, epoch, fit_bstar, fixed_bstar, catalog_number)
else {
break;
};
let Ok(satellite) = Satellite::from_elements_with_opsmode(&elements, opsmode) else {
break;
};
let Ok(prediction) = satellite.propagate_jd(epoch) else {
break;
};
let Ok(osc) = chart_from_state(prediction.position, prediction.velocity) else {
break;
};
let correction = [
target.n_rev_day - osc.n_rev_day,
target.af - osc.af,
target.ag - osc.ag,
target.chi - osc.chi,
target.psi - osc.psi,
wrap_to_pi(target.lam - osc.lam),
];
let correction_norm = correction.iter().map(|v| v * v).sum::<f64>().sqrt();
if correction_norm > previous_norm {
break;
}
let mut candidate = x.clone();
for i in 0..6 {
candidate[i] += correction[i];
}
if chart_to_elements(&candidate, epoch, fit_bstar, fixed_bstar, catalog_number).is_none() {
break;
}
x = candidate;
previous_norm = correction_norm;
passes += 1;
}
(x, passes)
}
fn validate_seed_propagates(
samples: &[FitSample],
elements: &ElementSet,
opsmode: OpsMode,
) -> Result<(), TleFitError> {
let satellite = Satellite::from_elements_with_opsmode(elements, opsmode).map_err(|source| {
TleFitError::SeedPropagation {
epoch_index: 0,
source,
}
})?;
for (i, sample) in samples.iter().enumerate() {
satellite
.propagate_jd(sample.epoch)
.map_err(|source| TleFitError::SeedPropagation {
epoch_index: i,
source,
})?;
}
Ok(())
}
fn build_fit(
problem: &Sgp4FitProblem<'_>,
config: &FitConfig,
result: TrfResult,
seed_refine_passes: usize,
) -> Result<TleFit, TleFitError> {
let elements = chart_to_elements(
&result.x,
problem.epoch,
problem.fit_bstar,
problem.fixed_bstar,
problem.catalog_number,
)
.ok_or(TleFitError::SolutionInfeasible)?;
let satellite = Satellite::from_elements_with_opsmode(&elements, problem.opsmode)
.map_err(TleFitError::FinalElements)?;
let tle_elements = tle_elements_from_fit(&elements, &config.metadata)?;
let (line1, line2) = tle::encode(&tle_elements);
let tle_satellite = Satellite::from_tle_with_opsmode(&line1, &line2, problem.opsmode)
.map_err(TleFitError::FinalElements)?;
let arc_stats = compute_arc_stats(problem, &satellite).map_err(TleFitError::FinalElements)?;
let tle_rms_position_km =
compute_tle_rms(problem, &tle_satellite).map_err(TleFitError::FinalElements)?;
let bstar_observable = bstar_observable(&result, problem.fit_bstar);
let omm = omm_from_fit(&elements, &config.metadata)?;
Ok(TleFit {
elements,
line1,
line2,
omm,
stats: FitStatistics {
rms_position_km: arc_stats.rms_position_km,
max_position_km: arc_stats.max_position_km,
rms_position_axes_km: arc_stats.rms_position_axes_km,
rms_velocity_km_s: arc_stats.rms_velocity_km_s,
tle_rms_position_km,
status: result.status,
nfev: result.nfev,
njev: result.njev,
cost: result.cost,
optimality: result.optimality,
bstar_observable,
seed_refine_passes,
},
})
}
#[derive(Debug, Clone, Copy)]
struct ArcStats {
rms_position_km: f64,
max_position_km: f64,
rms_position_axes_km: [f64; 3],
rms_velocity_km_s: Option<f64>,
}
fn compute_arc_stats(
problem: &Sgp4FitProblem<'_>,
satellite: &Satellite,
) -> Result<ArcStats, Sgp4Error> {
let mut sum_r2 = 0.0;
let mut max_r = 0.0;
let mut axis_sum = [0.0; 3];
let mut sum_v2 = 0.0;
for sample in problem.samples {
let prediction = satellite.propagate_jd(sample.epoch)?;
let mut r2 = 0.0;
for (axis, axis_total) in axis_sum.iter_mut().enumerate() {
let residual = prediction.position[axis] - sample.position_teme_km[axis];
r2 += residual * residual;
*axis_total += residual * residual;
}
let r = r2.sqrt();
sum_r2 += r2;
if r > max_r {
max_r = r;
}
if problem.use_velocity {
let velocity = sample
.velocity_teme_km_s
.expect("validated velocity presence");
let mut v2 = 0.0;
for (pred, target) in prediction.velocity.iter().zip(velocity) {
let residual = pred - target;
v2 += residual * residual;
}
sum_v2 += v2;
}
}
let n = problem.samples.len() as f64;
Ok(ArcStats {
rms_position_km: (sum_r2 / n).sqrt(),
max_position_km: max_r,
rms_position_axes_km: [
(axis_sum[0] / n).sqrt(),
(axis_sum[1] / n).sqrt(),
(axis_sum[2] / n).sqrt(),
],
rms_velocity_km_s: if problem.use_velocity {
Some((sum_v2 / n).sqrt())
} else {
None
},
})
}
fn compute_tle_rms(problem: &Sgp4FitProblem<'_>, satellite: &Satellite) -> Result<f64, Sgp4Error> {
let mut sum_r2 = 0.0;
for sample in problem.samples {
let prediction = satellite.propagate_jd(sample.epoch)?;
let mut r2 = 0.0;
for axis in 0..3 {
let residual = prediction.position[axis] - sample.position_teme_km[axis];
r2 += residual * residual;
}
sum_r2 += r2;
}
Ok((sum_r2 / problem.samples.len() as f64).sqrt())
}
fn bstar_observable(result: &TrfResult, fit_bstar: bool) -> bool {
if !fit_bstar {
return false;
}
let n = result.x.len();
if n == 0 || result.jac.is_empty() || !result.jac.len().is_multiple_of(n) {
return false;
}
let m = result.jac.len() / n;
let mut largest = 0.0_f64;
let mut bstar = 0.0_f64;
for col in 0..n {
let mut norm2 = 0.0;
for row in 0..m {
let value = result.jac[row * n + col];
norm2 += value * value;
}
let norm = norm2.sqrt();
if norm > largest {
largest = norm;
}
if col == n - 1 {
bstar = norm;
}
}
largest > 0.0 && bstar / largest >= BSTAR_OBSERVABLE_REL
}
fn chart_from_state(position: [f64; 3], velocity: [f64; 3]) -> Result<MeanChart, TleFitError> {
let coe =
rv2coe(position, velocity, MU_WGS72_KM3_S2).map_err(|_| TleFitError::NotElliptical)?;
chart_from_coe(&coe)
}
fn chart_from_coe(coe: &ClassicalElements) -> Result<MeanChart, TleFitError> {
if !coe.ecc.is_finite() || coe.ecc >= 1.0 || coe.ecc < 0.0 {
return Err(TleFitError::NotElliptical);
}
if !coe.a.is_finite() || coe.a <= 0.0 {
return Err(TleFitError::NotElliptical);
}
let (raan, argp, nu) = match coe.orbit_type {
OrbitType::EllipticalInclined => (coe.raan, coe.argp, coe.nu),
OrbitType::EllipticalEquatorial => (0.0, coe.lonper, coe.nu),
OrbitType::CircularInclined => (coe.raan, 0.0, coe.arglat),
OrbitType::CircularEquatorial => (0.0, 0.0, coe.truelon),
};
if !raan.is_finite() || !argp.is_finite() || !nu.is_finite() || !coe.incl.is_finite() {
return Err(TleFitError::NotElliptical);
}
let mean = true_to_mean(nu, coe.ecc).map_err(|_| TleFitError::NotElliptical)?;
let n_rad_s = (MU_WGS72_KM3_S2 / coe.a.powi(3)).sqrt();
let varpi = argp + raan;
let half_tan = (coe.incl * 0.5).tan();
Ok(MeanChart {
n_rev_day: n_rad_s * SECONDS_PER_DAY / TAU,
af: coe.ecc * varpi.cos(),
ag: coe.ecc * varpi.sin(),
chi: half_tan * raan.sin(),
psi: half_tan * raan.cos(),
lam: normalize_angle(mean + varpi),
})
}
fn chart_to_elements(
x: &[f64],
epoch: JulianDate,
fit_bstar: bool,
fixed_bstar: f64,
catalog_number: u32,
) -> Option<ElementSet> {
let expected = 6 + usize::from(fit_bstar);
if x.len() != expected || x.iter().any(|v| !v.is_finite()) {
return None;
}
let n_rev_day = x[0];
let af = x[1];
let ag = x[2];
let chi = x[3];
let psi = x[4];
let lam = x[5];
let ecc = af.hypot(ag);
if n_rev_day <= 0.0 || ecc >= ECC_MAX {
return None;
}
let bstar = if fit_bstar {
x[6] * BSTAR_SCALE
} else {
fixed_bstar
};
if !bstar.is_finite() {
return None;
}
let varpi = ag.atan2(af);
let incl = 2.0 * chi.hypot(psi).atan();
let raan = chi.atan2(psi);
let argp = varpi - raan;
let mean = lam - varpi;
Some(ElementSet {
epoch,
bstar,
mean_motion_dot: 0.0,
mean_motion_double_dot: 0.0,
eccentricity: ecc,
argument_of_perigee_deg: normalize_degrees(rad_to_deg(argp)),
inclination_deg: rad_to_deg(incl),
mean_anomaly_deg: normalize_degrees(rad_to_deg(mean)),
mean_motion_rev_per_day: n_rev_day,
right_ascension_deg: normalize_degrees(rad_to_deg(raan)),
catalog_number,
})
}
fn finite_difference_velocity(samples: &[FitSample], index: usize) -> [f64; 3] {
let (lo, hi) = if index == 0 {
(0, 1)
} else if index + 1 == samples.len() {
(samples.len() - 2, samples.len() - 1)
} else {
(index - 1, index + 1)
};
let dt = seconds_between(samples[hi].epoch, samples[lo].epoch);
let mut velocity = [0.0; 3];
for (axis, value) in velocity.iter_mut().enumerate() {
*value = (samples[hi].position_teme_km[axis] - samples[lo].position_teme_km[axis]) / dt;
}
velocity
}
fn tle_elements_from_fit(
elements: &ElementSet,
metadata: &TleMetadata,
) -> Result<TleElements, TleFitError> {
let (year, month, day, hour, minute, second) = civil_from_jd(elements.epoch);
let rev_number =
i32::try_from(metadata.rev_at_epoch).map_err(|_| TleFitError::InvalidInput {
field: "metadata.rev_at_epoch",
reason: "must fit i32",
})?;
Ok(TleElements {
catalog_number: format!("{:05}", metadata.catalog_number),
classification: metadata.classification.clone(),
international_designator: metadata.international_designator.clone(),
epoch_year: year as i32,
epoch_day_of_year: day_of_year(
year as i32,
month as i32,
day as i32,
hour as i32,
minute as i32,
second,
),
mean_motion_dot: 0.0,
mean_motion_double_dot: 0.0,
bstar: elements.bstar,
ephemeris_type: 0,
elset_number: metadata.element_set_number,
inclination_deg: elements.inclination_deg,
raan_deg: elements.right_ascension_deg,
eccentricity: elements.eccentricity,
arg_perigee_deg: elements.argument_of_perigee_deg,
mean_anomaly_deg: elements.mean_anomaly_deg,
mean_motion: elements.mean_motion_rev_per_day,
rev_number,
})
}
fn omm_from_fit(elements: &ElementSet, metadata: &TleMetadata) -> Result<Omm, TleFitError> {
Ok(Omm {
ccsds_omm_vers: "2.0".to_string(),
creation_date: None,
originator: None,
object_name: Some(metadata.object_name.clone()),
object_id: object_id_from_designator(&metadata.international_designator),
center_name: Some("EARTH".to_string()),
ref_frame: Some("TEME".to_string()),
time_system: Some("UTC".to_string()),
mean_element_theory: Some("SGP4".to_string()),
epoch: crate::astro::omm::OmmEpoch::from_sgp4_julian_date(elements.epoch),
mean_motion: elements.mean_motion_rev_per_day,
eccentricity: elements.eccentricity,
inclination_deg: elements.inclination_deg,
ra_of_asc_node_deg: elements.right_ascension_deg,
arg_of_pericenter_deg: elements.argument_of_perigee_deg,
mean_anomaly_deg: elements.mean_anomaly_deg,
ephemeris_type: 0,
classification_type: metadata.classification.clone(),
norad_cat_id: metadata.catalog_number,
element_set_no: metadata.element_set_number,
rev_at_epoch: metadata.rev_at_epoch,
bstar: elements.bstar,
mean_motion_dot: 0.0,
mean_motion_ddot: 0.0,
exact_sgp4_epoch: Some(elements.epoch),
quantize_tle_derived_fields: false,
})
}
fn object_id_from_designator(designator: &str) -> Option<String> {
let trimmed = designator.trim();
if trimmed.is_empty() {
return None;
}
let bytes = trimmed.as_bytes();
if bytes.len() >= 5 && bytes[..5].iter().all(u8::is_ascii_digit) {
let yy: i32 = std::str::from_utf8(&bytes[..2]).ok()?.parse().ok()?;
let year = if yy < 57 { 2000 + yy } else { 1900 + yy };
let number = std::str::from_utf8(&bytes[2..5]).ok()?;
let piece = std::str::from_utf8(&bytes[5..]).ok()?;
Some(format!("{year:04}-{number}{piece}"))
} else {
Some(trimmed.to_string())
}
}
fn civil_from_jd(epoch: JulianDate) -> (i64, i64, i64, i64, i64, f64) {
let (midnight, fraction) = if (epoch.0.fract().abs() - 0.5).abs() < 1.0e-9 {
(epoch.0, epoch.1)
} else if epoch.1 >= 0.5 {
(epoch.0 + 0.5, epoch.1 - 0.5)
} else {
(epoch.0 - 0.5, epoch.1 + 0.5)
};
civil_from_split_julian_date(midnight, fraction)
}
fn solver_options(config: &FitConfig) -> TrfOptions {
let mut options = TrfOptions::default();
if let Some(ftol) = config.ftol {
options.ftol = ftol;
}
if let Some(xtol) = config.xtol {
options.xtol = xtol;
}
if let Some(gtol) = config.gtol {
options.gtol = gtol;
}
options.max_nfev = config.max_nfev;
options.x_scale = config.x_scale.clone().unwrap_or(XScale::Jac);
options.loss = config.loss;
options.f_scale = config.f_scale;
options
}
fn resolve_epoch(
samples: &[FitSample],
epoch: FitEpoch,
) -> Result<(JulianDate, usize), TleFitError> {
match epoch {
FitEpoch::Midpoint => {
let first = samples[0].epoch;
let last = samples[samples.len() - 1].epoch;
let mid_seconds = seconds_between(last, first) * 0.5;
let target = add_seconds(first, mid_seconds);
let index = nearest_sample(samples, target);
Ok((samples[index].epoch, index))
}
FitEpoch::First => Ok((samples[0].epoch, 0)),
FitEpoch::Last => {
let index = samples.len() - 1;
Ok((samples[index].epoch, index))
}
FitEpoch::Sample(index) => samples
.get(index)
.map(|sample| (sample.epoch, index))
.ok_or(TleFitError::InvalidInput {
field: "epoch",
reason: "sample index out of bounds",
}),
FitEpoch::Jd(jd) => {
validate_epoch(jd)?;
if seconds_between(jd, samples[0].epoch) < 0.0
|| seconds_between(samples[samples.len() - 1].epoch, jd) < 0.0
{
return Err(TleFitError::EpochOutsideArc);
}
Ok((jd, nearest_sample(samples, jd)))
}
}
}
fn nearest_sample(samples: &[FitSample], target: JulianDate) -> usize {
let mut best = 0;
let mut best_dt = seconds_between(samples[0].epoch, target).abs();
for (i, sample) in samples.iter().enumerate().skip(1) {
let dt = seconds_between(sample.epoch, target).abs();
if dt < best_dt {
best = i;
best_dt = dt;
}
}
best
}
fn add_seconds(epoch: JulianDate, seconds: f64) -> JulianDate {
let mut whole = epoch.0;
let mut fraction = epoch.1 + seconds / SECONDS_PER_DAY;
let carry = fraction.floor();
whole += carry;
fraction -= carry;
JulianDate(whole, fraction)
}
fn validate_epoch(epoch: JulianDate) -> Result<(), TleFitError> {
validate_finite("epoch.whole", epoch.0)?;
validate_finite("epoch.fraction", epoch.1)?;
if !(0.0..1.0).contains(&epoch.1) {
return Err(TleFitError::InvalidInput {
field: "epoch.fraction",
reason: "must be in [0, 1)",
});
}
Ok(())
}
fn validate_vec3(field: &'static str, values: &[f64; 3]) -> Result<(), TleFitError> {
for value in values {
validate_finite(field, *value)?;
}
Ok(())
}
fn validate_optional_positive(field: &'static str, value: Option<f64>) -> Result<(), TleFitError> {
if let Some(value) = value {
validate_positive(field, value)?;
}
Ok(())
}
fn validate_positive(field: &'static str, value: f64) -> Result<(), TleFitError> {
validate_finite(field, value)?;
if value <= 0.0 {
return Err(TleFitError::InvalidInput {
field,
reason: "must be > 0",
});
}
Ok(())
}
fn validate_finite(field: &'static str, value: f64) -> Result<(), TleFitError> {
if value.is_finite() {
Ok(())
} else {
Err(TleFitError::InvalidInput {
field,
reason: "must be finite",
})
}
}
fn seconds_between(later: JulianDate, earlier: JulianDate) -> f64 {
((later.0 - earlier.0) + (later.1 - earlier.1)) * SECONDS_PER_DAY
}
#[cfg(test)]
fn deg_to_rad(deg: f64) -> f64 {
deg * std::f64::consts::PI / 180.0
}
fn rad_to_deg(rad: f64) -> f64 {
rad * 180.0 / std::f64::consts::PI
}
fn normalize_angle(angle: f64) -> f64 {
let mut out = angle % TAU;
if out < 0.0 {
out += TAU;
}
out
}
fn wrap_to_pi(angle: f64) -> f64 {
let mut out = (angle + std::f64::consts::PI) % TAU;
if out <= 0.0 {
out += TAU;
}
out - std::f64::consts::PI
}
fn normalize_degrees(degrees: f64) -> f64 {
let mut out = degrees % 360.0;
if out < 0.0 {
out += 360.0;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::astro::frames::transforms::{gcrs_to_teme_compute, TemeStateKm};
use crate::astro::omm::{encode_kvn, parse_kvn};
use crate::astro::propagator::{propagate_states, PropagationConfig, PropagationForceModel};
use crate::astro::time::civil::split_julian_date;
use crate::astro::time::scales::TimeScales;
use crate::astro::tle;
use crate::constants::J2000_JD;
use trust_region_least_squares::model::ResidualModel;
const ISS_L1: &str = "1 25544U 98067A 26168.18949189 .00009113 00000+0 17172-3 0 9996";
const ISS_L2: &str = "2 25544 51.6332 300.0813 0004737 195.1146 164.9702 15.49273435571752";
const GEO_L1: &str = "1 28884U 05041A 26167.71607684 -.00000267 00000+0 00000+0 0 9995";
const GEO_L2: &str = "2 28884 3.5359 77.2731 0014354 137.8081 105.3728 0.98943614 75438";
const DECAY_L1: &str = "1 28872U 05037B 05333.02012661 .25992681 00000-0 24476-3 0 1534";
const DECAY_L2: &str = "2 28872 96.4736 157.9986 0303955 244.0492 110.6523 16.46015938 10708";
const SSO_L1: &str = "1 28057U 03049A 06177.78615833 .00000060 00000-0 35970-4 0 1836";
const SSO_L2: &str = "2 28057 98.4283 247.6961 0000884 88.1964 271.9322 14.35478080140550";
fn arc_from_tle(line1: &str, line2: &str, offsets_min: &[f64]) -> Vec<FitSample> {
let satellite = Satellite::from_tle(line1, line2).expect("valid TLE");
let epoch = satellite.epoch_jd();
offsets_min
.iter()
.map(|&minutes| {
let prediction = satellite
.propagate(super::super::MinutesSinceEpoch(minutes))
.expect("propagates");
FitSample {
epoch: add_seconds(epoch, minutes * 60.0),
position_teme_km: prediction.position,
velocity_teme_km_s: Some(prediction.velocity),
}
})
.collect()
}
fn arc_from_elements(elements: &ElementSet, offsets_min: &[f64]) -> Vec<FitSample> {
let satellite = Satellite::from_elements(elements).expect("valid elements");
offsets_min
.iter()
.map(|&minutes| {
let prediction = satellite
.propagate(super::super::MinutesSinceEpoch(minutes))
.expect("propagates");
FitSample {
epoch: add_seconds(elements.epoch, minutes * 60.0),
position_teme_km: prediction.position,
velocity_teme_km_s: Some(prediction.velocity),
}
})
.collect()
}
fn metadata_from_tle(line1: &str, line2: &str) -> TleMetadata {
let parsed = tle::parse(line1, line2).expect("parse").elements;
TleMetadata {
catalog_number: parsed.catalog_number.parse().unwrap(),
classification: parsed.classification,
international_designator: parsed.international_designator,
element_set_number: parsed.elset_number,
rev_at_epoch: parsed.rev_number as i64,
object_name: String::new(),
}
}
fn metadata_for_catalog(catalog_number: u32) -> TleMetadata {
TleMetadata {
catalog_number,
classification: "U".to_string(),
international_designator: String::new(),
element_set_number: 999,
rev_at_epoch: 0,
object_name: String::new(),
}
}
fn chart_delta(a: &ElementSet, b: &ElementSet) -> [f64; 6] {
let ca = MeanChart::from_elements(a);
let cb = MeanChart::from_elements(b);
[
ca.n_rev_day - cb.n_rev_day,
ca.af - cb.af,
ca.ag - cb.ag,
ca.chi - cb.chi,
ca.psi - cb.psi,
wrap_to_pi(ca.lam - cb.lam),
]
}
fn assert_element_sets_bit_identical(a: &ElementSet, b: &ElementSet) {
assert_eq!(a.epoch.0.to_bits(), b.epoch.0.to_bits(), "epoch whole");
assert_eq!(a.epoch.1.to_bits(), b.epoch.1.to_bits(), "epoch frac");
assert_eq!(a.bstar.to_bits(), b.bstar.to_bits(), "bstar");
assert_eq!(
a.mean_motion_dot.to_bits(),
b.mean_motion_dot.to_bits(),
"mean motion dot"
);
assert_eq!(
a.mean_motion_double_dot.to_bits(),
b.mean_motion_double_dot.to_bits(),
"mean motion ddot"
);
assert_eq!(a.eccentricity.to_bits(), b.eccentricity.to_bits(), "ecc");
assert_eq!(
a.argument_of_perigee_deg.to_bits(),
b.argument_of_perigee_deg.to_bits(),
"argp"
);
assert_eq!(
a.inclination_deg.to_bits(),
b.inclination_deg.to_bits(),
"incl"
);
assert_eq!(
a.mean_anomaly_deg.to_bits(),
b.mean_anomaly_deg.to_bits(),
"mean anomaly"
);
assert_eq!(
a.mean_motion_rev_per_day.to_bits(),
b.mean_motion_rev_per_day.to_bits(),
"mean motion"
);
assert_eq!(
a.right_ascension_deg.to_bits(),
b.right_ascension_deg.to_bits(),
"raan"
);
assert_eq!(a.catalog_number, b.catalog_number, "catalog");
}
fn assert_satellites_bit_identical(a: &Satellite, b: &Satellite) {
let ea = a.epoch_jd();
let eb = b.epoch_jd();
assert_eq!(ea.0.to_bits(), eb.0.to_bits(), "sat epoch whole");
assert_eq!(ea.1.to_bits(), eb.1.to_bits(), "sat epoch fraction");
for minutes in [-120.0, 0.0, 120.0] {
let pa = a
.propagate(super::super::MinutesSinceEpoch(minutes))
.expect("left propagates");
let pb = b
.propagate(super::super::MinutesSinceEpoch(minutes))
.expect("right propagates");
for axis in 0..3 {
assert_eq!(
pa.position[axis].to_bits(),
pb.position[axis].to_bits(),
"position axis {axis} at {minutes} min"
);
assert_eq!(
pa.velocity[axis].to_bits(),
pb.velocity[axis].to_bits(),
"velocity axis {axis} at {minutes} min"
);
}
}
}
fn assert_fit_bit_identical(a: &TleFit, b: &TleFit) {
assert_element_sets_bit_identical(&a.elements, &b.elements);
assert_eq!(a.line1, b.line1);
assert_eq!(a.line2, b.line2);
assert_eq!(a.omm, b.omm);
assert_eq!(a.stats, b.stats);
}
fn rms_against_samples(samples: &[FitSample], satellite: &Satellite) -> f64 {
let mut sum = 0.0;
for sample in samples {
let prediction = satellite
.propagate_jd(sample.epoch)
.expect("sample propagates");
let mut r2 = 0.0;
for axis in 0..3 {
let residual = prediction.position[axis] - sample.position_teme_km[axis];
r2 += residual * residual;
}
sum += r2;
}
(sum / samples.len() as f64).sqrt()
}
fn seed_rms(samples: &[FitSample], config: &FitConfig) -> f64 {
let resolved = validate_and_resolve(samples, config).expect("valid fit input");
let (x0, _) = initial_guess(samples, config, &resolved).expect("seed");
let seed = chart_to_elements(
&x0,
resolved.epoch,
config.fit_bstar,
config.bstar_seed,
config.metadata.catalog_number,
)
.expect("seed elements");
let satellite =
Satellite::from_elements_with_opsmode(&seed, config.opsmode).expect("seed satellite");
rms_against_samples(samples, &satellite)
}
fn clean_position_rms(samples: &[FitSample], satellite: &Satellite) -> f64 {
rms_against_samples(samples, satellite)
}
fn fit_round_trip_case(
name: &str,
truth: &ElementSet,
samples: &[FitSample],
max_rms_km: f64,
max_chart_delta: f64,
) {
let config = FitConfig {
epoch: FitEpoch::Jd(truth.epoch),
fit_bstar: false,
metadata: metadata_for_catalog(truth.catalog_number),
max_nfev: Some(120),
..FitConfig::default()
};
let fit = fit_tle(samples, &config).unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(
fit.stats.rms_position_km <= max_rms_km,
"{name} rms {}",
fit.stats.rms_position_km
);
let delta = chart_delta(&fit.elements, truth);
for value in delta {
assert!(value.abs() <= max_chart_delta, "{name} chart delta {value}");
}
}
fn utc_time_scales_with_offset(offset_s: f64) -> TimeScales {
let hour = (offset_s / 3600.0).floor() as i32;
let rem = offset_s - hour as f64 * 3600.0;
let minute = (rem / 60.0).floor() as i32;
let second = rem - minute as f64 * 60.0;
TimeScales::from_utc(2026, 6, 17, hour, minute, second).expect("time scales")
}
fn numerical_teme_arc(offsets_s: &[f64], include_velocity: bool) -> Vec<FitSample> {
let start = TimeScales::from_utc(2026, 6, 17, 0, 0, 0.0).expect("start time");
let start_tdb_s = (start.jd_tdb - J2000_JD) * SECONDS_PER_DAY;
let config = PropagationConfig {
force_model: PropagationForceModel::TwoBodyJ2,
..PropagationConfig::new(start_tdb_s, [7000.0, -1210.0, 1300.0], [1.2, 7.35, 0.92])
};
let epochs: Vec<f64> = offsets_s
.iter()
.map(|offset| start_tdb_s + offset)
.collect();
let states = propagate_states(&config, &epochs).expect("numerical propagation");
states
.iter()
.zip(offsets_s)
.map(|(state, &offset_s)| {
let ts = utc_time_scales_with_offset(offset_s);
let gcrs = TemeStateKm {
position_km: state.position_array(),
velocity_km_s: state.velocity_array(),
};
let (position, velocity) =
gcrs_to_teme_compute(&gcrs, &ts, false).expect("GCRS to TEME");
let (jd_whole, fraction) = split_julian_date(2026, 6, 17, 0, 0, offset_s);
FitSample {
epoch: JulianDate(jd_whole, fraction),
position_teme_km: [position.0, position.1, position.2],
velocity_teme_km_s: include_velocity
.then_some([velocity.0, velocity.1, velocity.2]),
}
})
.collect()
}
#[test]
fn chart_round_trips_degenerate_angles() {
let cases = [
ElementSet {
epoch: JulianDate(2_460_000.5, 0.0),
bstar: 0.0,
mean_motion_dot: 0.0,
mean_motion_double_dot: 0.0,
eccentricity: 0.0,
argument_of_perigee_deg: 0.0,
inclination_deg: 0.0,
mean_anomaly_deg: 42.0,
mean_motion_rev_per_day: 1.0027,
right_ascension_deg: 0.0,
catalog_number: 1,
},
tle::parse(ISS_L1, ISS_L2)
.unwrap()
.elements
.to_element_set()
.unwrap(),
tle::parse(GEO_L1, GEO_L2)
.unwrap()
.elements
.to_element_set()
.unwrap(),
];
for elements in cases {
let chart = MeanChart::from_elements(&elements).to_vec(true, elements.bstar);
let round =
chart_to_elements(&chart, elements.epoch, true, 0.0, elements.catalog_number)
.expect("chart domain");
let delta = chart_delta(&elements, &round);
for value in delta {
assert!(value.abs() <= 1.0e-12, "delta {value}");
}
assert!((elements.bstar - round.bstar).abs() <= 1.0e-15);
}
}
#[test]
fn penalty_path_keeps_residual_length() {
let samples = arc_from_tle(ISS_L1, ISS_L2, &[-20.0, 0.0, 20.0]);
let problem = Sgp4FitProblem {
samples: &samples,
epoch: samples[1].epoch,
opsmode: OpsMode::Improved,
fit_bstar: true,
fixed_bstar: 0.0,
catalog_number: 25544,
weights: vec![1.0; samples.len()],
w_vel: 900.0,
use_velocity: true,
penalty_hit_at_solution: Cell::new(false),
};
let mut out = Vec::new();
problem.residual(&[15.0, ECC_MAX, 0.0, 0.0, 0.0, 0.0, 0.0], &mut out);
assert_eq!(out.len(), samples.len() * 6);
assert!(out.iter().all(|&value| value == PENALTY_KM));
assert!(problem.penalty_hit_at_solution.get());
}
#[test]
fn round_trip_recovers_iss_elements() {
let offsets: Vec<f64> = (-36..=36).map(|i| i as f64 * 10.0).collect();
let samples = arc_from_tle(ISS_L1, ISS_L2, &offsets);
let truth = tle::parse(ISS_L1, ISS_L2)
.unwrap()
.elements
.to_element_set()
.unwrap();
let config = FitConfig {
epoch: FitEpoch::Jd(truth.epoch),
metadata: metadata_from_tle(ISS_L1, ISS_L2),
max_nfev: Some(80),
..FitConfig::default()
};
let fit = fit_tle(&samples, &config).expect("fit converges");
assert!(fit.stats.status >= 1);
assert!(
fit.stats.rms_position_km <= 1.0e-3,
"rms {}",
fit.stats.rms_position_km
);
let delta = chart_delta(&fit.elements, &truth);
assert!(delta[0].abs() <= 1.0e-8, "n {}", delta[0]);
for value in &delta[1..5] {
assert!(value.abs() <= 1.0e-7, "chart {value}");
}
assert!(delta[5].abs() <= 1.0e-6, "lam {}", delta[5]);
assert!(fit.stats.tle_rms_position_km <= 0.1);
assert_eq!(fit.elements.mean_motion_dot, 0.0);
assert_eq!(fit.elements.mean_motion_double_dot, 0.0);
assert_eq!(fit.omm.mean_motion_dot, 0.0);
assert_eq!(fit.omm.mean_motion_ddot, 0.0);
let omm_elements = fit.omm.to_element_set().expect("fitted OMM bridge");
assert_element_sets_bit_identical(&omm_elements, &fit.elements);
let from_omm = Satellite::from_omm(&fit.omm).expect("fitted OMM satellite");
let from_elements =
Satellite::from_elements(&fit.elements).expect("fitted element satellite");
assert_satellites_bit_identical(&from_omm, &from_elements);
let encoded_omm = encode_kvn(&fit.omm);
let reparsed_omm = parse_kvn(&encoded_omm).expect("encoded fitted OMM reparses");
let _ = reparsed_omm
.to_element_set()
.expect("encoded fitted OMM bridges");
}
#[test]
fn geo_short_arc_marks_bstar_unobservable() {
let offsets: Vec<f64> = (-12..=12).map(|i| i as f64 * 30.0).collect();
let samples = arc_from_tle(GEO_L1, GEO_L2, &offsets);
let truth = tle::parse(GEO_L1, GEO_L2)
.unwrap()
.elements
.to_element_set()
.unwrap();
let config = FitConfig {
epoch: FitEpoch::Jd(truth.epoch),
metadata: metadata_from_tle(GEO_L1, GEO_L2),
max_nfev: Some(80),
..FitConfig::default()
};
let fit = fit_tle(&samples, &config).expect("fit converges");
assert!(fit.stats.rms_position_km <= 1.0e-3);
assert!(!fit.stats.bstar_observable);
}
#[test]
fn high_drag_bstar_recovery_is_observable() {
let offsets: Vec<f64> = (0..=10).map(|i| i as f64 * 5.0).collect();
let samples = arc_from_tle(DECAY_L1, DECAY_L2, &offsets);
let truth = tle::parse(DECAY_L1, DECAY_L2)
.unwrap()
.elements
.to_element_set()
.unwrap();
let config = FitConfig {
epoch: FitEpoch::Jd(truth.epoch),
metadata: metadata_from_tle(DECAY_L1, DECAY_L2),
max_nfev: Some(120),
..FitConfig::default()
};
let fit = fit_tle(&samples, &config).expect("high-drag fit converges");
assert!(fit.stats.bstar_observable);
assert!(
(fit.elements.bstar - truth.bstar).abs() <= 1.0e-6,
"bstar fit {} truth {}",
fit.elements.bstar,
truth.bstar
);
}
#[test]
fn fitted_omm_epoch_survives_text_round_trip() {
let truth = tle::parse(ISS_L1, ISS_L2)
.unwrap()
.elements
.to_element_set()
.unwrap();
let metadata = metadata_from_tle(ISS_L1, ISS_L2);
let omm = omm_from_fit(&truth, &metadata).expect("fitted OMM");
let mut reparsed = parse_kvn(&encode_kvn(&omm)).expect("fitted OMM reparses");
assert_eq!(reparsed.epoch, omm.epoch);
assert_eq!(reparsed.exact_sgp4_epoch, None);
reparsed.quantize_tle_derived_fields = false;
let in_memory_elements = omm.to_element_set().expect("in-memory bridge");
let rebuilt = reparsed.to_element_set().expect("reparsed OMM bridges");
assert_element_sets_bit_identical(&rebuilt, &in_memory_elements);
let direct = Satellite::from_omm(&omm).expect("direct satellite");
let round_tripped = Satellite::from_omm(&reparsed).expect("round-tripped satellite");
for minutes in [0.0, 45.0, 720.0] {
let jd = add_seconds(truth.epoch, minutes * 60.0);
let a = direct.propagate_jd(jd).expect("direct propagates");
let b = round_tripped.propagate_jd(jd).expect("reparsed propagates");
assert_eq!(a.position, b.position, "position at {minutes} min");
assert_eq!(a.velocity, b.velocity, "velocity at {minutes} min");
}
let JulianDate(whole, fraction) = truth.epoch;
let shifted = if fraction >= 0.5 {
JulianDate(whole + 0.5, fraction - 0.5)
} else {
JulianDate(whole - 0.5, fraction + 0.5)
};
assert_eq!(shifted.0 + shifted.1, whole + fraction);
let mut shifted_elements = truth.clone();
shifted_elements.epoch = shifted;
let omm2 = omm_from_fit(&shifted_elements, &metadata).expect("shifted fitted OMM");
let in_memory = omm2.to_element_set().expect("in-memory bridge").epoch;
assert_eq!(in_memory.0.to_bits(), shifted.0.to_bits());
assert_eq!(in_memory.1.to_bits(), shifted.1.to_bits());
let mut reparsed2 = parse_kvn(&encode_kvn(&omm2)).expect("shifted OMM reparses");
reparsed2.quantize_tle_derived_fields = false;
let rebuilt2 = reparsed2.to_element_set().expect("shifted bridge").epoch;
assert_eq!(rebuilt2.0.to_bits(), whole.to_bits());
assert_eq!(rebuilt2.1.to_bits(), fraction.to_bits());
assert_eq!(rebuilt2.0 + rebuilt2.1, shifted.0 + shifted.1);
let in_memory_sat = Satellite::from_omm(&omm2).expect("in-memory satellite");
let round_tripped2 = Satellite::from_omm(&reparsed2).expect("round-tripped satellite");
for minutes in [0.0, 45.0, 720.0] {
let jd = add_seconds(truth.epoch, minutes * 60.0);
let a = in_memory_sat
.propagate_jd(jd)
.expect("in-memory propagates");
let b = round_tripped2
.propagate_jd(jd)
.expect("reparsed propagates");
for axis in 0..3 {
assert!(
(a.position[axis] - b.position[axis]).abs() <= 1.0e-9,
"axis {axis} at {minutes} min: {} vs {}",
a.position[axis],
b.position[axis]
);
}
}
}
#[test]
fn molniya_and_sun_sync_round_trip_cases_converge() {
let molniya = ElementSet {
epoch: JulianDate(2_461_208.5, 0.317_123_456_789_012),
bstar: 0.0,
mean_motion_dot: 0.0,
mean_motion_double_dot: 0.0,
eccentricity: 0.742_8,
argument_of_perigee_deg: 270.5,
inclination_deg: 63.4,
mean_anomaly_deg: 15.8,
mean_motion_rev_per_day: 2.006_1,
right_ascension_deg: 90.16,
catalog_number: 28163,
};
let molniya_offsets: Vec<f64> = (-12..=12).map(|i| i as f64 * 120.0).collect();
let molniya_samples = arc_from_elements(&molniya, &molniya_offsets);
fit_round_trip_case("molniya", &molniya, &molniya_samples, 1.0e-3, 2.0e-6);
let sso_truth = tle::parse(SSO_L1, SSO_L2)
.unwrap()
.elements
.to_element_set()
.unwrap();
let sso_offsets: Vec<f64> = (-24..=24).map(|i| i as f64 * 10.0).collect();
let sso_samples = arc_from_tle(SSO_L1, SSO_L2, &sso_offsets);
fit_round_trip_case("sun-sync", &sso_truth, &sso_samples, 1.0e-3, 2.0e-6);
}
#[test]
fn raan_singular_geo_round_trip_converges() {
let truth = ElementSet {
epoch: JulianDate(2_461_208.5, 0.625_987_654_321_098),
bstar: 0.0,
mean_motion_dot: 0.0,
mean_motion_double_dot: 0.0,
eccentricity: 0.0012,
argument_of_perigee_deg: 137.8,
inclination_deg: 0.05,
mean_anomaly_deg: 105.4,
mean_motion_rev_per_day: 1.0027,
right_ascension_deg: 77.3,
catalog_number: 39000,
};
let offsets: Vec<f64> = (-12..=12).map(|i| i as f64 * 120.0).collect();
let samples = arc_from_elements(&truth, &offsets);
fit_round_trip_case("raan-singular geo", &truth, &samples, 1.0e-3, 2.0e-6);
}
#[test]
fn numerical_cross_source_oracle_improves_seed_position_only() {
let offsets_s: Vec<f64> = (0..=18).map(|i| i as f64 * 300.0).collect();
let samples = numerical_teme_arc(&offsets_s, false);
let config = FitConfig {
epoch: FitEpoch::Midpoint,
fit_bstar: false,
use_velocity: false,
metadata: metadata_for_catalog(60001),
max_nfev: Some(120),
..FitConfig::default()
};
let seed = seed_rms(&samples, &config);
let fit = fit_tle(&samples, &config).expect("numerical position fit");
assert!(
fit.stats.rms_position_km < seed,
"fit rms {} seed rms {}",
fit.stats.rms_position_km,
seed
);
assert!(!fit.stats.bstar_observable);
}
#[test]
fn numerical_cross_source_oracle_improves_seed_with_velocity() {
let offsets_s: Vec<f64> = (0..=18).map(|i| i as f64 * 300.0).collect();
let samples = numerical_teme_arc(&offsets_s, true);
let config = FitConfig {
epoch: FitEpoch::Midpoint,
fit_bstar: false,
use_velocity: true,
metadata: metadata_for_catalog(60002),
max_nfev: Some(120),
..FitConfig::default()
};
let seed = seed_rms(&samples, &config);
let fit = fit_tle(&samples, &config).expect("numerical position+velocity fit");
assert!(
fit.stats.rms_position_km < seed,
"fit rms {} seed rms {}",
fit.stats.rms_position_km,
seed
);
assert!(fit.stats.rms_velocity_km_s.is_some());
assert!(!fit.stats.bstar_observable);
}
#[test]
fn seed_refinement_improves_initial_guess() {
let samples = arc_from_tle(ISS_L1, ISS_L2, &[-30.0, -15.0, 0.0, 15.0, 30.0]);
let config = FitConfig {
epoch: FitEpoch::Sample(2),
metadata: metadata_from_tle(ISS_L1, ISS_L2),
..FitConfig::default()
};
let resolved = validate_and_resolve(&samples, &config).expect("valid fit input");
let sample = samples[resolved.epoch_index];
let target = chart_from_state(sample.position_teme_km, sample.velocity_teme_km_s.unwrap())
.expect("target chart");
let raw = target.to_vec(config.fit_bstar, config.bstar_seed);
let raw_elements = chart_to_elements(
&raw,
resolved.epoch,
config.fit_bstar,
config.bstar_seed,
config.metadata.catalog_number,
)
.expect("raw seed");
let raw_satellite = Satellite::from_elements(&raw_elements).expect("raw seed satellite");
let raw_rms = rms_against_samples(&samples, &raw_satellite);
let (refined, passes) = refine_seed(
raw,
target,
resolved.epoch,
config.opsmode,
config.fit_bstar,
config.bstar_seed,
config.metadata.catalog_number,
);
let refined_elements = chart_to_elements(
&refined,
resolved.epoch,
config.fit_bstar,
config.bstar_seed,
config.metadata.catalog_number,
)
.expect("refined seed");
let refined_satellite =
Satellite::from_elements(&refined_elements).expect("refined seed satellite");
let refined_rms = rms_against_samples(&samples, &refined_satellite);
assert!(passes > 0);
assert!(
refined_rms < raw_rms,
"refined rms {refined_rms} raw rms {raw_rms}"
);
}
#[test]
fn seed_propagation_reports_typed_error() {
let elements = tle::parse(DECAY_L1, DECAY_L2)
.unwrap()
.elements
.to_element_set()
.unwrap();
let samples = [
FitSample {
epoch: elements.epoch,
position_teme_km: [7000.0, 0.0, 0.0],
velocity_teme_km_s: Some([0.0, 7.5, 0.0]),
},
FitSample {
epoch: add_seconds(elements.epoch, 1440.0 * 60.0),
position_teme_km: [7000.0, 0.0, 0.0],
velocity_teme_km_s: Some([0.0, 7.5, 0.0]),
},
];
let err = validate_seed_propagates(&samples, &elements, OpsMode::Improved)
.expect_err("decayed seed must error");
assert!(matches!(
err,
TleFitError::SeedPropagation { epoch_index: 1, .. }
));
}
#[test]
fn epoch_selection_modes_resolve_expected_epochs() {
let samples = arc_from_tle(ISS_L1, ISS_L2, &[-20.0, -10.0, 0.0, 10.0, 20.0]);
for (mode, expected_index) in [
(FitEpoch::First, 0),
(FitEpoch::Midpoint, 2),
(FitEpoch::Last, 4),
(FitEpoch::Sample(3), 3),
] {
let config = FitConfig {
epoch: mode,
..FitConfig::default()
};
let resolved = validate_and_resolve(&samples, &config).expect("resolve epoch");
assert_eq!(resolved.epoch_index, expected_index);
assert_eq!(resolved.epoch, samples[expected_index].epoch);
}
let explicit = add_seconds(samples[0].epoch, 15.0 * 60.0);
let config = FitConfig {
epoch: FitEpoch::Jd(explicit),
..FitConfig::default()
};
let resolved = validate_and_resolve(&samples, &config).expect("resolve JD epoch");
assert_eq!(resolved.epoch, explicit);
assert_eq!(resolved.epoch_index, 1);
let outside = add_seconds(samples[0].epoch, -1.0);
let config = FitConfig {
epoch: FitEpoch::Jd(outside),
..FitConfig::default()
};
assert!(matches!(
validate_and_resolve(&samples, &config),
Err(TleFitError::EpochOutsideArc)
));
}
#[test]
fn sample_weights_reduce_outlier_leverage() {
let offsets: Vec<f64> = (-12..=12).map(|i| i as f64 * 5.0).collect();
let clean = arc_from_tle(ISS_L1, ISS_L2, &offsets);
let mut corrupted = clean.clone();
let outlier = corrupted.len() / 2;
corrupted[outlier].position_teme_km[0] += 8.0;
corrupted[outlier].position_teme_km[1] -= 5.0;
let base_config = FitConfig {
epoch: FitEpoch::Jd(clean[outlier].epoch),
metadata: metadata_from_tle(ISS_L1, ISS_L2),
max_nfev: Some(100),
..FitConfig::default()
};
let unweighted = fit_tle(&corrupted, &base_config).expect("unweighted fit");
let unweighted_sat =
Satellite::from_elements(&unweighted.elements).expect("unweighted satellite");
let unweighted_clean_rms = clean_position_rms(&clean, &unweighted_sat);
let mut weights = vec![1.0; corrupted.len()];
weights[outlier] = 0.01;
let weighted_config = FitConfig {
weights: Some(weights),
..base_config
};
let weighted = fit_tle(&corrupted, &weighted_config).expect("weighted fit");
let weighted_sat =
Satellite::from_elements(&weighted.elements).expect("weighted satellite");
let weighted_clean_rms = clean_position_rms(&clean, &weighted_sat);
assert!(
weighted_clean_rms < unweighted_clean_rms,
"weighted clean rms {weighted_clean_rms} unweighted {unweighted_clean_rms}"
);
}
#[test]
fn deterministic_fit_repeats_bit_identically() {
let samples = arc_from_tle(ISS_L1, ISS_L2, &[-30.0, -15.0, 0.0, 15.0, 30.0]);
let config = FitConfig {
epoch: FitEpoch::Sample(2),
metadata: metadata_from_tle(ISS_L1, ISS_L2),
max_nfev: Some(80),
..FitConfig::default()
};
let a = fit_tle(&samples, &config).expect("first fit");
let b = fit_tle(&samples, &config).expect("second fit");
assert_fit_bit_identical(&a, &b);
}
#[test]
fn iss_ninety_minute_arc_marks_bstar_unobservable() {
let offsets: Vec<f64> = (-9..=9).map(|i| i as f64 * 5.0).collect();
let samples = arc_from_tle(ISS_L1, ISS_L2, &offsets);
let truth = tle::parse(ISS_L1, ISS_L2)
.unwrap()
.elements
.to_element_set()
.unwrap();
let config = FitConfig {
epoch: FitEpoch::Jd(truth.epoch),
metadata: metadata_from_tle(ISS_L1, ISS_L2),
max_nfev: Some(80),
..FitConfig::default()
};
let fit = fit_tle(&samples, &config).expect("ISS short arc fit");
assert!(!fit.stats.bstar_observable);
}
#[test]
fn validation_reports_typed_errors() {
let mut samples = arc_from_tle(ISS_L1, ISS_L2, &[-10.0, 0.0, 10.0]);
samples[1].epoch = samples[0].epoch;
assert!(matches!(
fit_tle(&samples, &FitConfig::default()),
Err(TleFitError::EpochsNotIncreasing { index: 1 })
));
let mut samples = arc_from_tle(ISS_L1, ISS_L2, &[-10.0, 0.0, 10.0]);
samples[1].velocity_teme_km_s = None;
assert!(matches!(
fit_tle(&samples, &FitConfig::default()),
Err(TleFitError::MixedVelocityPresence)
));
let samples = arc_from_tle(ISS_L1, ISS_L2, &[-10.0, 0.0, 10.0]);
let mut config = FitConfig::default();
config.metadata.classification = "X".to_string();
assert!(matches!(
fit_tle(&samples, &config),
Err(TleFitError::InvalidInput {
field: "metadata.classification",
..
})
));
}
#[test]
fn did_not_converge_carries_best_effort_fit() {
let offsets: Vec<f64> = (-6..=6).map(|i| i as f64 * 10.0).collect();
let samples = arc_from_tle(ISS_L1, ISS_L2, &offsets);
let truth = tle::parse(ISS_L1, ISS_L2)
.unwrap()
.elements
.to_element_set()
.unwrap();
let config = FitConfig {
epoch: FitEpoch::Jd(truth.epoch),
metadata: metadata_from_tle(ISS_L1, ISS_L2),
max_nfev: Some(2),
..FitConfig::default()
};
let err = fit_tle(&samples, &config).expect_err("budget stops");
match err {
TleFitError::DidNotConverge { result } => {
assert_eq!(result.stats.status, 0);
assert!(!result.line1.is_empty());
assert!(!result.line2.is_empty());
}
other => panic!("unexpected error {other:?}"),
}
}
}