Skip to main content

sidereon_core/astro/propagator/
decay.rs

1//! Orbital decay helper built on the numerical propagator and drag force.
2//!
3//! The scan samples geodetic altitude on a coarse elapsed-time grid and refines
4//! the first endpoint that falls at or below the reentry altitude. A coarse step
5//! longer than the time spent below the threshold at perigee can miss a brief
6//! crossing; callers should keep `scan_step_s` well below the orbital period,
7//! for example no more than about one twentieth of the period in near-circular
8//! decay cases.
9
10use crate::astro::atmosphere::MAX_ALTITUDE_KM;
11use crate::astro::constants::{J2_EARTH, MU_EARTH, RE_EARTH};
12use crate::astro::error::PropagationError;
13use crate::astro::events::root::{try_bisect_crossing_until, RootError};
14use crate::astro::forces::drag::geodetic_altitude_km;
15use crate::astro::forces::{DragForce, DragParameters, SpaceWeatherSource};
16use crate::astro::propagator::api::IntegratorOptions;
17use crate::astro::propagator::driver::PropagationForceModel;
18use crate::astro::propagator::numerical::{ForceModelKind, IntegratorKind, StatePropagator};
19use crate::astro::state::CartesianState;
20
21/// Result of an orbital-lifetime estimate.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct DecayEstimate {
24    /// Seconds from the initial epoch to reentry.
25    pub time_to_decay_s: f64,
26    /// State at reentry.
27    pub reentry_state: CartesianState,
28    /// Geodetic altitude at the reported state, km.
29    pub reentry_altitude_km: f64,
30}
31
32/// Configuration for a decay run.
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct DecayConfig {
35    /// Gravity choice layered under drag.
36    pub force_model: PropagationForceModel,
37    /// Gravitational-parameter override, km^3/s^2.
38    pub mu_km3_s2: Option<f64>,
39    /// Numerical integrator.
40    pub integrator: IntegratorKind,
41    /// Integrator options.
42    pub options: IntegratorOptions,
43    /// Drag parameters.
44    pub drag: DragParameters,
45    /// Reentry threshold altitude, km.
46    pub reentry_altitude_km: f64,
47    /// Coarse scan step, s.
48    pub scan_step_s: f64,
49    /// Bisection time tolerance, s.
50    pub crossing_tolerance_s: f64,
51    /// Maximum elapsed scan horizon, s.
52    pub max_duration_s: f64,
53    /// Maximum number of coarse scan samples.
54    pub max_scan_samples: u32,
55}
56
57impl DecayConfig {
58    /// Default reentry altitude, km.
59    pub const DEFAULT_REENTRY_ALTITUDE_KM: f64 = DragForce::DEFAULT_REENTRY_ALTITUDE_KM;
60    /// Default coarse scan step, s.
61    pub const DEFAULT_SCAN_STEP_S: f64 = 60.0;
62    /// Default bisection time tolerance, s.
63    pub const DEFAULT_CROSSING_TOLERANCE_S: f64 = 1.0;
64    /// Default maximum coarse scan samples.
65    pub const DEFAULT_MAX_SCAN_SAMPLES: u32 = 200_000;
66    /// Default maximum elapsed scan horizon, s.
67    pub const DEFAULT_MAX_DURATION_S: f64 =
68        Self::DEFAULT_MAX_SCAN_SAMPLES as f64 * Self::DEFAULT_SCAN_STEP_S;
69
70    /// Build a decay config around required drag parameters.
71    pub fn new(drag: DragParameters) -> Self {
72        Self {
73            force_model: PropagationForceModel::default(),
74            mu_km3_s2: None,
75            integrator: IntegratorKind::Dp54,
76            options: IntegratorOptions::default(),
77            drag,
78            reentry_altitude_km: Self::DEFAULT_REENTRY_ALTITUDE_KM,
79            scan_step_s: Self::DEFAULT_SCAN_STEP_S,
80            crossing_tolerance_s: Self::DEFAULT_CROSSING_TOLERANCE_S,
81            max_duration_s: Self::DEFAULT_MAX_DURATION_S,
82            max_scan_samples: Self::DEFAULT_MAX_SCAN_SAMPLES,
83        }
84    }
85
86    /// Replace the gravity choice.
87    pub fn with_force_model(mut self, force_model: PropagationForceModel) -> Self {
88        self.force_model = force_model;
89        self
90    }
91
92    /// Replace the gravitational parameter override.
93    pub fn with_mu_km3_s2(mut self, mu_km3_s2: Option<f64>) -> Self {
94        self.mu_km3_s2 = mu_km3_s2;
95        self
96    }
97
98    /// Replace the integrator.
99    pub fn with_integrator(mut self, integrator: IntegratorKind) -> Self {
100        self.integrator = integrator;
101        self
102    }
103
104    /// Replace the integrator options.
105    pub fn with_options(mut self, options: IntegratorOptions) -> Self {
106        self.options = options;
107        self
108    }
109
110    /// Replace the reentry altitude, km.
111    pub fn with_reentry_altitude_km(mut self, reentry_altitude_km: f64) -> Self {
112        self.reentry_altitude_km = reentry_altitude_km;
113        self
114    }
115
116    /// Replace the coarse scan step, s.
117    pub fn with_scan_step_s(mut self, scan_step_s: f64) -> Self {
118        self.scan_step_s = scan_step_s;
119        self
120    }
121
122    /// Replace the crossing tolerance, s.
123    pub fn with_crossing_tolerance_s(mut self, crossing_tolerance_s: f64) -> Self {
124        self.crossing_tolerance_s = crossing_tolerance_s;
125        self
126    }
127
128    /// Replace the maximum scan horizon, s.
129    pub fn with_max_duration_s(mut self, max_duration_s: f64) -> Self {
130        self.max_duration_s = max_duration_s;
131        self
132    }
133
134    /// Replace the maximum coarse sample count.
135    pub fn with_max_scan_samples(mut self, max_scan_samples: u32) -> Self {
136        self.max_scan_samples = max_scan_samples;
137        self
138    }
139}
140
141/// Error returned by [`estimate_decay`].
142#[derive(Debug, Clone, thiserror::Error)]
143pub enum DecayError {
144    /// Propagation or altitude evaluation failed.
145    #[error("propagation failed: {0}")]
146    Propagation(PropagationError),
147    /// A config field was out of domain.
148    #[error("invalid decay config field: {0}")]
149    InvalidConfig(&'static str),
150    /// The orbit did not decay within the requested horizon.
151    #[error("no decay within horizon {horizon_s} s")]
152    NoDecayWithinHorizon { horizon_s: f64 },
153    /// The coarse scan hit its sample budget before the horizon.
154    #[error("scan budget exhausted after {samples} samples and {scanned_s} s")]
155    ScanBudgetExhausted { scanned_s: f64, samples: u32 },
156}
157
158/// Estimate time to reentry using drag-perturbed numerical propagation.
159pub fn estimate_decay(
160    initial: CartesianState,
161    config: &DecayConfig,
162) -> Result<DecayEstimate, DecayError> {
163    estimate_decay_driver(initial, config, None)
164}
165
166/// Estimate time to reentry using per-epoch space weather from `source`.
167///
168/// `config.drag` supplies the ballistic coefficient and cutoff altitude; its
169/// fixed [`crate::astro::forces::SpaceWeather`] value is not consulted.
170pub fn estimate_decay_with_source(
171    initial: CartesianState,
172    config: &DecayConfig,
173    source: &SpaceWeatherSource,
174) -> Result<DecayEstimate, DecayError> {
175    estimate_decay_driver(initial, config, Some(source))
176}
177
178fn estimate_decay_driver(
179    initial: CartesianState,
180    config: &DecayConfig,
181    source: Option<&SpaceWeatherSource>,
182) -> Result<DecayEstimate, DecayError> {
183    validate_config(config)?;
184
185    let initial_altitude = geodetic_altitude_km(&initial).map_err(DecayError::Propagation)?;
186    if initial_altitude <= config.reentry_altitude_km {
187        return Ok(DecayEstimate {
188            time_to_decay_s: 0.0,
189            reentry_state: initial,
190            reentry_altitude_km: initial_altitude,
191        });
192    }
193
194    let initial_epoch = initial.epoch_tdb_seconds;
195    let mut elapsed_s = 0.0;
196    let mut samples = 0_u32;
197    let mut current = initial;
198
199    loop {
200        if elapsed_s >= config.max_duration_s {
201            return Err(DecayError::NoDecayWithinHorizon {
202                horizon_s: config.max_duration_s,
203            });
204        }
205        if samples >= config.max_scan_samples {
206            return Err(DecayError::ScanBudgetExhausted {
207                scanned_s: elapsed_s,
208                samples,
209            });
210        }
211
212        let next_elapsed_s = (elapsed_s + config.scan_step_s).min(config.max_duration_s);
213        let next_state =
214            propagate_segment(current, initial_epoch + next_elapsed_s, config, source)?;
215        samples += 1;
216        let next_altitude = geodetic_altitude_km(&next_state).map_err(DecayError::Propagation)?;
217
218        if next_altitude <= config.reentry_altitude_km {
219            return refine_reentry(
220                initial_epoch,
221                current,
222                elapsed_s,
223                next_elapsed_s,
224                config,
225                source,
226            );
227        }
228
229        elapsed_s = next_elapsed_s;
230        current = next_state;
231    }
232}
233
234fn validate_config(config: &DecayConfig) -> Result<(), DecayError> {
235    validate_positive("scan_step_s", config.scan_step_s)?;
236    validate_positive("crossing_tolerance_s", config.crossing_tolerance_s)?;
237    validate_positive("max_duration_s", config.max_duration_s)?;
238    if config.max_scan_samples == 0 {
239        return Err(DecayError::InvalidConfig("max_scan_samples"));
240    }
241    if !config.reentry_altitude_km.is_finite() {
242        return Err(DecayError::InvalidConfig("reentry_altitude_km"));
243    }
244    if !(0.0..=MAX_ALTITUDE_KM).contains(&config.reentry_altitude_km) {
245        return Err(DecayError::InvalidConfig("reentry_altitude_km"));
246    }
247    if config.reentry_altitude_km < config.drag.cutoff_altitude_km() {
248        return Err(DecayError::InvalidConfig("reentry_altitude_km"));
249    }
250    Ok(())
251}
252
253fn validate_positive(field: &'static str, value: f64) -> Result<(), DecayError> {
254    if !value.is_finite() || value <= 0.0 {
255        return Err(DecayError::InvalidConfig(field));
256    }
257    Ok(())
258}
259
260fn refine_reentry(
261    initial_epoch: f64,
262    low_state: CartesianState,
263    low_elapsed_s: f64,
264    high_elapsed_s: f64,
265    config: &DecayConfig,
266    source: Option<&SpaceWeatherSource>,
267) -> Result<DecayEstimate, DecayError> {
268    let threshold = config.reentry_altitude_km;
269    let root_elapsed_s = try_bisect_crossing_until(
270        low_elapsed_s,
271        high_elapsed_s,
272        |elapsed_s| {
273            let state = propagate_segment(low_state, initial_epoch + elapsed_s, config, source)?;
274            let altitude = geodetic_altitude_km(&state).map_err(DecayError::Propagation)?;
275            Ok(altitude - threshold)
276        },
277        |lo, hi| (lo + hi) * 0.5,
278        |lo, hi| (hi - lo).abs() <= config.crossing_tolerance_s,
279    )
280    .map_err(map_root_error)?;
281
282    let reentry_state =
283        propagate_segment(low_state, initial_epoch + root_elapsed_s, config, source)?;
284    let reentry_altitude_km =
285        geodetic_altitude_km(&reentry_state).map_err(DecayError::Propagation)?;
286    Ok(DecayEstimate {
287        time_to_decay_s: reentry_state.epoch_tdb_seconds - initial_epoch,
288        reentry_state,
289        reentry_altitude_km,
290    })
291}
292
293fn map_root_error(error: RootError<DecayError>) -> DecayError {
294    match error {
295        RootError::Predicate(error) => error,
296        RootError::InvalidInput { field, reason } => DecayError::Propagation(
297            PropagationError::NumericalFailure(format!("drag decay bisection {field} {reason}")),
298        ),
299    }
300}
301
302fn propagate_segment(
303    initial: CartesianState,
304    t_end_tdb_seconds: f64,
305    config: &DecayConfig,
306    source: Option<&SpaceWeatherSource>,
307) -> Result<CartesianState, DecayError> {
308    let propagator = StatePropagator {
309        initial,
310        force_model: force_model_kind(config),
311        integrator: config.integrator,
312        options: config.options,
313        drag: Some(config.drag),
314        space_weather: source.cloned(),
315    };
316    Ok(propagator
317        .propagate_to(t_end_tdb_seconds)
318        .map_err(DecayError::Propagation)?
319        .final_state)
320}
321
322fn force_model_kind(config: &DecayConfig) -> ForceModelKind {
323    let mu_km3_s2 = config.mu_km3_s2.unwrap_or(MU_EARTH);
324    match config.force_model {
325        PropagationForceModel::TwoBody => ForceModelKind::TwoBody { mu_km3_s2 },
326        PropagationForceModel::TwoBodyJ2 => ForceModelKind::TwoBodyJ2 {
327            mu_km3_s2,
328            re_km: RE_EARTH,
329            j2: J2_EARTH,
330        },
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::astro::constants::RE_EARTH;
338    use crate::astro::forces::SpaceWeather;
339    use crate::astro::frames::transforms::{
340        geodetic_to_itrs, greenwich_mean_sidereal_time_radians_from_j2000_seconds,
341    };
342
343    const TEST_EPOCH_S: f64 = 646_315_200.0;
344
345    fn state_from_geodetic_alt(epoch: f64, altitude_km: f64) -> CartesianState {
346        let (x_ecef, y_ecef, z_ecef) =
347            geodetic_to_itrs(0.0, 0.0, altitude_km).expect("valid geodetic");
348        let theta =
349            greenwich_mean_sidereal_time_radians_from_j2000_seconds(epoch).expect("valid gmst");
350        let c = theta.cos();
351        let s = theta.sin();
352        let x_eci = c * x_ecef - s * y_ecef;
353        let y_eci = s * x_ecef + c * y_ecef;
354        let r = RE_EARTH + altitude_km;
355        let v = (MU_EARTH / r).sqrt();
356        CartesianState::new(
357            epoch,
358            [x_eci, y_eci, z_ecef],
359            [-v * y_eci / r, v * x_eci / r, 0.0],
360        )
361    }
362
363    fn decay_drag() -> DragParameters {
364        DragParameters::from_bc_factor_m2_kg(
365            0.8,
366            SpaceWeather::default(),
367            DragForce::DEFAULT_REENTRY_ALTITUDE_KM,
368        )
369        .expect("valid drag")
370    }
371
372    fn decay_options() -> IntegratorOptions {
373        IntegratorOptions {
374            abs_tol: 1.0e-8,
375            rel_tol: 1.0e-10,
376            initial_step: 5.0,
377            min_step: 1.0e-6,
378            max_step: 30.0,
379            max_steps: 200_000,
380            dense_output: false,
381        }
382    }
383
384    fn base_config() -> DecayConfig {
385        DecayConfig::new(decay_drag())
386            .with_options(decay_options())
387            .with_scan_step_s(60.0)
388            .with_crossing_tolerance_s(2.0)
389            .with_max_duration_s(50_000.0)
390            .with_max_scan_samples(2_000)
391    }
392
393    #[test]
394    fn estimate_decay_brackets_and_refines_reentry() {
395        let initial = state_from_geodetic_alt(TEST_EPOCH_S, 125.0);
396        let config = base_config();
397        let estimate = estimate_decay(initial, &config).expect("decays within horizon");
398
399        assert!(estimate.time_to_decay_s > 0.0);
400        assert_eq!(
401            estimate.time_to_decay_s.to_bits(),
402            (estimate.reentry_state.epoch_tdb_seconds - initial.epoch_tdb_seconds).to_bits()
403        );
404        let before = propagate_segment(
405            initial,
406            initial.epoch_tdb_seconds + estimate.time_to_decay_s - config.crossing_tolerance_s,
407            &config,
408            None,
409        )
410        .expect("before crossing");
411        let after = propagate_segment(
412            initial,
413            initial.epoch_tdb_seconds + estimate.time_to_decay_s + config.crossing_tolerance_s,
414            &config,
415            None,
416        )
417        .expect("after crossing");
418        let before_alt = geodetic_altitude_km(&before).expect("before altitude");
419        let after_alt = geodetic_altitude_km(&after).expect("after altitude");
420        let altitude_window_km = (before_alt - after_alt).abs().max(1.0e-6);
421        assert!(
422            (estimate.reentry_altitude_km - config.reentry_altitude_km).abs() <= altitude_window_km
423        );
424
425        let high = state_from_geodetic_alt(TEST_EPOCH_S, 700.0);
426        let no_decay = base_config()
427            .with_max_duration_s(120.0)
428            .with_max_scan_samples(10);
429        match estimate_decay(high, &no_decay).expect_err("short horizon should stop") {
430            DecayError::NoDecayWithinHorizon { horizon_s } => assert_eq!(horizon_s, 120.0),
431            other => panic!("expected horizon stop, got {other:?}"),
432        }
433
434        let budget = base_config()
435            .with_max_duration_s(10_000.0)
436            .with_max_scan_samples(2);
437        match estimate_decay(high, &budget).expect_err("sample budget should stop") {
438            DecayError::ScanBudgetExhausted { scanned_s, samples } => {
439                assert_eq!(scanned_s, 120.0);
440                assert_eq!(samples, 2);
441            }
442            other => panic!("expected scan budget stop, got {other:?}"),
443        }
444    }
445
446    #[test]
447    fn estimate_decay_zero_when_initial_below_threshold() {
448        let initial = state_from_geodetic_alt(TEST_EPOCH_S, 90.0);
449        let estimate = estimate_decay(initial, &base_config()).expect("initial is below threshold");
450
451        assert_eq!(estimate.time_to_decay_s, 0.0);
452        assert_eq!(estimate.reentry_state, initial);
453        assert!(estimate.reentry_altitude_km <= DecayConfig::DEFAULT_REENTRY_ALTITUDE_KM);
454    }
455}