1use 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#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct DecayEstimate {
24 pub time_to_decay_s: f64,
26 pub reentry_state: CartesianState,
28 pub reentry_altitude_km: f64,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct DecayConfig {
35 pub force_model: PropagationForceModel,
37 pub mu_km3_s2: Option<f64>,
39 pub integrator: IntegratorKind,
41 pub options: IntegratorOptions,
43 pub drag: DragParameters,
45 pub reentry_altitude_km: f64,
47 pub scan_step_s: f64,
49 pub crossing_tolerance_s: f64,
51 pub max_duration_s: f64,
53 pub max_scan_samples: u32,
55}
56
57impl DecayConfig {
58 pub const DEFAULT_REENTRY_ALTITUDE_KM: f64 = DragForce::DEFAULT_REENTRY_ALTITUDE_KM;
60 pub const DEFAULT_SCAN_STEP_S: f64 = 60.0;
62 pub const DEFAULT_CROSSING_TOLERANCE_S: f64 = 1.0;
64 pub const DEFAULT_MAX_SCAN_SAMPLES: u32 = 200_000;
66 pub const DEFAULT_MAX_DURATION_S: f64 =
68 Self::DEFAULT_MAX_SCAN_SAMPLES as f64 * Self::DEFAULT_SCAN_STEP_S;
69
70 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 pub fn with_force_model(mut self, force_model: PropagationForceModel) -> Self {
88 self.force_model = force_model;
89 self
90 }
91
92 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 pub fn with_integrator(mut self, integrator: IntegratorKind) -> Self {
100 self.integrator = integrator;
101 self
102 }
103
104 pub fn with_options(mut self, options: IntegratorOptions) -> Self {
106 self.options = options;
107 self
108 }
109
110 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 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 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 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 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#[derive(Debug, Clone, thiserror::Error)]
143pub enum DecayError {
144 #[error("propagation failed: {0}")]
146 Propagation(PropagationError),
147 #[error("invalid decay config field: {0}")]
149 InvalidConfig(&'static str),
150 #[error("no decay within horizon {horizon_s} s")]
152 NoDecayWithinHorizon { horizon_s: f64 },
153 #[error("scan budget exhausted after {samples} samples and {scanned_s} s")]
155 ScanBudgetExhausted { scanned_s: f64, samples: u32 },
156}
157
158pub fn estimate_decay(
160 initial: CartesianState,
161 config: &DecayConfig,
162) -> Result<DecayEstimate, DecayError> {
163 estimate_decay_driver(initial, config, None)
164}
165
166pub 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}