use crate::astro::atmosphere::MAX_ALTITUDE_KM;
use crate::astro::constants::{J2_EARTH, MU_EARTH, RE_EARTH};
use crate::astro::error::PropagationError;
use crate::astro::events::root::{try_bisect_crossing_until, RootError};
use crate::astro::forces::drag::geodetic_altitude_km;
use crate::astro::forces::{DragForce, DragParameters};
use crate::astro::propagator::api::IntegratorOptions;
use crate::astro::propagator::driver::PropagationForceModel;
use crate::astro::propagator::numerical::{ForceModelKind, IntegratorKind, StatePropagator};
use crate::astro::state::CartesianState;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DecayEstimate {
pub time_to_decay_s: f64,
pub reentry_state: CartesianState,
pub reentry_altitude_km: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DecayConfig {
pub force_model: PropagationForceModel,
pub mu_km3_s2: Option<f64>,
pub integrator: IntegratorKind,
pub options: IntegratorOptions,
pub drag: DragParameters,
pub reentry_altitude_km: f64,
pub scan_step_s: f64,
pub crossing_tolerance_s: f64,
pub max_duration_s: f64,
pub max_scan_samples: u32,
}
impl DecayConfig {
pub const DEFAULT_REENTRY_ALTITUDE_KM: f64 = DragForce::DEFAULT_REENTRY_ALTITUDE_KM;
pub const DEFAULT_SCAN_STEP_S: f64 = 60.0;
pub const DEFAULT_CROSSING_TOLERANCE_S: f64 = 1.0;
pub const DEFAULT_MAX_SCAN_SAMPLES: u32 = 200_000;
pub const DEFAULT_MAX_DURATION_S: f64 =
Self::DEFAULT_MAX_SCAN_SAMPLES as f64 * Self::DEFAULT_SCAN_STEP_S;
pub fn new(drag: DragParameters) -> Self {
Self {
force_model: PropagationForceModel::default(),
mu_km3_s2: None,
integrator: IntegratorKind::Dp54,
options: IntegratorOptions::default(),
drag,
reentry_altitude_km: Self::DEFAULT_REENTRY_ALTITUDE_KM,
scan_step_s: Self::DEFAULT_SCAN_STEP_S,
crossing_tolerance_s: Self::DEFAULT_CROSSING_TOLERANCE_S,
max_duration_s: Self::DEFAULT_MAX_DURATION_S,
max_scan_samples: Self::DEFAULT_MAX_SCAN_SAMPLES,
}
}
pub fn with_force_model(mut self, force_model: PropagationForceModel) -> Self {
self.force_model = force_model;
self
}
pub fn with_mu_km3_s2(mut self, mu_km3_s2: Option<f64>) -> Self {
self.mu_km3_s2 = mu_km3_s2;
self
}
pub fn with_integrator(mut self, integrator: IntegratorKind) -> Self {
self.integrator = integrator;
self
}
pub fn with_options(mut self, options: IntegratorOptions) -> Self {
self.options = options;
self
}
pub fn with_reentry_altitude_km(mut self, reentry_altitude_km: f64) -> Self {
self.reentry_altitude_km = reentry_altitude_km;
self
}
pub fn with_scan_step_s(mut self, scan_step_s: f64) -> Self {
self.scan_step_s = scan_step_s;
self
}
pub fn with_crossing_tolerance_s(mut self, crossing_tolerance_s: f64) -> Self {
self.crossing_tolerance_s = crossing_tolerance_s;
self
}
pub fn with_max_duration_s(mut self, max_duration_s: f64) -> Self {
self.max_duration_s = max_duration_s;
self
}
pub fn with_max_scan_samples(mut self, max_scan_samples: u32) -> Self {
self.max_scan_samples = max_scan_samples;
self
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum DecayError {
#[error("propagation failed: {0}")]
Propagation(PropagationError),
#[error("invalid decay config field: {0}")]
InvalidConfig(&'static str),
#[error("no decay within horizon {horizon_s} s")]
NoDecayWithinHorizon { horizon_s: f64 },
#[error("scan budget exhausted after {samples} samples and {scanned_s} s")]
ScanBudgetExhausted { scanned_s: f64, samples: u32 },
}
pub fn estimate_decay(
initial: CartesianState,
config: &DecayConfig,
) -> Result<DecayEstimate, DecayError> {
validate_config(config)?;
let initial_altitude = geodetic_altitude_km(&initial).map_err(DecayError::Propagation)?;
if initial_altitude <= config.reentry_altitude_km {
return Ok(DecayEstimate {
time_to_decay_s: 0.0,
reentry_state: initial,
reentry_altitude_km: initial_altitude,
});
}
let initial_epoch = initial.epoch_tdb_seconds;
let mut elapsed_s = 0.0;
let mut samples = 0_u32;
let mut current = initial;
loop {
if elapsed_s >= config.max_duration_s {
return Err(DecayError::NoDecayWithinHorizon {
horizon_s: config.max_duration_s,
});
}
if samples >= config.max_scan_samples {
return Err(DecayError::ScanBudgetExhausted {
scanned_s: elapsed_s,
samples,
});
}
let next_elapsed_s = (elapsed_s + config.scan_step_s).min(config.max_duration_s);
let next_state = propagate_segment(current, initial_epoch + next_elapsed_s, config)?;
samples += 1;
let next_altitude = geodetic_altitude_km(&next_state).map_err(DecayError::Propagation)?;
if next_altitude <= config.reentry_altitude_km {
return refine_reentry(initial_epoch, current, elapsed_s, next_elapsed_s, config);
}
elapsed_s = next_elapsed_s;
current = next_state;
}
}
fn validate_config(config: &DecayConfig) -> Result<(), DecayError> {
validate_positive("scan_step_s", config.scan_step_s)?;
validate_positive("crossing_tolerance_s", config.crossing_tolerance_s)?;
validate_positive("max_duration_s", config.max_duration_s)?;
if config.max_scan_samples == 0 {
return Err(DecayError::InvalidConfig("max_scan_samples"));
}
if !config.reentry_altitude_km.is_finite() {
return Err(DecayError::InvalidConfig("reentry_altitude_km"));
}
if !(0.0..=MAX_ALTITUDE_KM).contains(&config.reentry_altitude_km) {
return Err(DecayError::InvalidConfig("reentry_altitude_km"));
}
if config.reentry_altitude_km < config.drag.cutoff_altitude_km() {
return Err(DecayError::InvalidConfig("reentry_altitude_km"));
}
Ok(())
}
fn validate_positive(field: &'static str, value: f64) -> Result<(), DecayError> {
if !value.is_finite() || value <= 0.0 {
return Err(DecayError::InvalidConfig(field));
}
Ok(())
}
fn refine_reentry(
initial_epoch: f64,
low_state: CartesianState,
low_elapsed_s: f64,
high_elapsed_s: f64,
config: &DecayConfig,
) -> Result<DecayEstimate, DecayError> {
let threshold = config.reentry_altitude_km;
let root_elapsed_s = try_bisect_crossing_until(
low_elapsed_s,
high_elapsed_s,
|elapsed_s| {
let state = propagate_segment(low_state, initial_epoch + elapsed_s, config)?;
let altitude = geodetic_altitude_km(&state).map_err(DecayError::Propagation)?;
Ok(altitude - threshold)
},
|lo, hi| (lo + hi) * 0.5,
|lo, hi| (hi - lo).abs() <= config.crossing_tolerance_s,
)
.map_err(map_root_error)?;
let reentry_state = propagate_segment(low_state, initial_epoch + root_elapsed_s, config)?;
let reentry_altitude_km =
geodetic_altitude_km(&reentry_state).map_err(DecayError::Propagation)?;
Ok(DecayEstimate {
time_to_decay_s: reentry_state.epoch_tdb_seconds - initial_epoch,
reentry_state,
reentry_altitude_km,
})
}
fn map_root_error(error: RootError<DecayError>) -> DecayError {
match error {
RootError::Predicate(error) => error,
RootError::InvalidInput { field, reason } => DecayError::Propagation(
PropagationError::NumericalFailure(format!("drag decay bisection {field} {reason}")),
),
}
}
fn propagate_segment(
initial: CartesianState,
t_end_tdb_seconds: f64,
config: &DecayConfig,
) -> Result<CartesianState, DecayError> {
let propagator = StatePropagator {
initial,
force_model: force_model_kind(config),
integrator: config.integrator,
options: config.options,
drag: Some(config.drag),
};
Ok(propagator
.propagate_to(t_end_tdb_seconds)
.map_err(DecayError::Propagation)?
.final_state)
}
fn force_model_kind(config: &DecayConfig) -> ForceModelKind {
let mu_km3_s2 = config.mu_km3_s2.unwrap_or(MU_EARTH);
match config.force_model {
PropagationForceModel::TwoBody => ForceModelKind::TwoBody { mu_km3_s2 },
PropagationForceModel::TwoBodyJ2 => ForceModelKind::TwoBodyJ2 {
mu_km3_s2,
re_km: RE_EARTH,
j2: J2_EARTH,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::astro::constants::RE_EARTH;
use crate::astro::forces::SpaceWeather;
use crate::astro::frames::transforms::{
geodetic_to_itrs, greenwich_mean_sidereal_time_radians_from_j2000_seconds,
};
const TEST_EPOCH_S: f64 = 646_315_200.0;
fn state_from_geodetic_alt(epoch: f64, altitude_km: f64) -> CartesianState {
let (x_ecef, y_ecef, z_ecef) =
geodetic_to_itrs(0.0, 0.0, altitude_km).expect("valid geodetic");
let theta =
greenwich_mean_sidereal_time_radians_from_j2000_seconds(epoch).expect("valid gmst");
let c = theta.cos();
let s = theta.sin();
let x_eci = c * x_ecef - s * y_ecef;
let y_eci = s * x_ecef + c * y_ecef;
let r = RE_EARTH + altitude_km;
let v = (MU_EARTH / r).sqrt();
CartesianState::new(
epoch,
[x_eci, y_eci, z_ecef],
[-v * y_eci / r, v * x_eci / r, 0.0],
)
}
fn decay_drag() -> DragParameters {
DragParameters::from_bc_factor_m2_kg(
0.8,
SpaceWeather::default(),
DragForce::DEFAULT_REENTRY_ALTITUDE_KM,
)
.expect("valid drag")
}
fn decay_options() -> IntegratorOptions {
IntegratorOptions {
abs_tol: 1.0e-8,
rel_tol: 1.0e-10,
initial_step: 5.0,
min_step: 1.0e-6,
max_step: 30.0,
max_steps: 200_000,
dense_output: false,
}
}
fn base_config() -> DecayConfig {
DecayConfig::new(decay_drag())
.with_options(decay_options())
.with_scan_step_s(60.0)
.with_crossing_tolerance_s(2.0)
.with_max_duration_s(50_000.0)
.with_max_scan_samples(2_000)
}
#[test]
fn estimate_decay_brackets_and_refines_reentry() {
let initial = state_from_geodetic_alt(TEST_EPOCH_S, 125.0);
let config = base_config();
let estimate = estimate_decay(initial, &config).expect("decays within horizon");
assert!(estimate.time_to_decay_s > 0.0);
assert_eq!(
estimate.time_to_decay_s.to_bits(),
(estimate.reentry_state.epoch_tdb_seconds - initial.epoch_tdb_seconds).to_bits()
);
let before = propagate_segment(
initial,
initial.epoch_tdb_seconds + estimate.time_to_decay_s - config.crossing_tolerance_s,
&config,
)
.expect("before crossing");
let after = propagate_segment(
initial,
initial.epoch_tdb_seconds + estimate.time_to_decay_s + config.crossing_tolerance_s,
&config,
)
.expect("after crossing");
let before_alt = geodetic_altitude_km(&before).expect("before altitude");
let after_alt = geodetic_altitude_km(&after).expect("after altitude");
let altitude_window_km = (before_alt - after_alt).abs().max(1.0e-6);
assert!(
(estimate.reentry_altitude_km - config.reentry_altitude_km).abs() <= altitude_window_km
);
let high = state_from_geodetic_alt(TEST_EPOCH_S, 700.0);
let no_decay = base_config()
.with_max_duration_s(120.0)
.with_max_scan_samples(10);
match estimate_decay(high, &no_decay).expect_err("short horizon should stop") {
DecayError::NoDecayWithinHorizon { horizon_s } => assert_eq!(horizon_s, 120.0),
other => panic!("expected horizon stop, got {other:?}"),
}
let budget = base_config()
.with_max_duration_s(10_000.0)
.with_max_scan_samples(2);
match estimate_decay(high, &budget).expect_err("sample budget should stop") {
DecayError::ScanBudgetExhausted { scanned_s, samples } => {
assert_eq!(scanned_s, 120.0);
assert_eq!(samples, 2);
}
other => panic!("expected scan budget stop, got {other:?}"),
}
}
#[test]
fn estimate_decay_zero_when_initial_below_threshold() {
let initial = state_from_geodetic_alt(TEST_EPOCH_S, 90.0);
let estimate = estimate_decay(initial, &base_config()).expect("initial is below threshold");
assert_eq!(estimate.time_to_decay_s, 0.0);
assert_eq!(estimate.reentry_state, initial);
assert!(estimate.reentry_altitude_km <= DecayConfig::DEFAULT_REENTRY_ALTITUDE_KM);
}
}