1use crate::astro::atmosphere::{self, NrlmsiseInput, MAX_ALTITUDE_KM};
17use crate::astro::constants::{
18 earth::OMEGA_E_DOT_RAD_S,
19 time::{DAYS_PER_JULIAN_YEAR, SECONDS_PER_DAY},
20 units::M_PER_KM,
21};
22use crate::astro::error::PropagationError;
23use crate::astro::forces::r#trait::ForceModel;
24use crate::astro::frames::transforms::{
25 greenwich_mean_sidereal_time_radians_from_j2000_seconds, itrs_to_geodetic_compute,
26 FrameTransformError,
27};
28use crate::astro::propagator::api::PropagationContext;
29use crate::astro::space_weather::{SpaceWeatherError, SpaceWeatherTable};
30use crate::astro::state::CartesianState;
31use crate::astro::time::civil::{civil_from_j2000_seconds, day_of_year_int, second_of_day};
32use nalgebra::Vector3;
33use std::sync::Arc;
34
35const MAX_EPOCH_OFFSET_S: f64 = 1000.0 * DAYS_PER_JULIAN_YEAR * SECONDS_PER_DAY;
36
37#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct SpaceWeather {
40 pub f107: f64,
42 pub f107a: f64,
44 pub ap: f64,
46}
47
48impl Default for SpaceWeather {
49 fn default() -> Self {
51 Self {
52 f107: atmosphere::DEFAULT_F107,
53 f107a: atmosphere::DEFAULT_F107A,
54 ap: atmosphere::DEFAULT_AP,
55 }
56 }
57}
58
59#[derive(Debug, Clone)]
61pub enum SpaceWeatherSource {
62 Fixed(SpaceWeather),
64 Table(Arc<SpaceWeatherTable>),
66}
67
68impl SpaceWeatherSource {
69 pub fn at(&self, epoch_j2000_s: f64) -> Result<SpaceWeather, SpaceWeatherError> {
71 match self {
72 Self::Fixed(space_weather) => Ok(*space_weather),
73 Self::Table(table) => table.space_weather_at(epoch_j2000_s),
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq)]
84pub struct DragForce {
85 bc_factor_m2_kg: f64,
86 space_weather: SpaceWeather,
87 cutoff_altitude_km: f64,
88}
89
90impl DragForce {
91 pub const DEFAULT_REENTRY_ALTITUDE_KM: f64 = 100.0;
93
94 pub fn from_area_mass(
96 cd: f64,
97 area_m2: f64,
98 mass_kg: f64,
99 sw: SpaceWeather,
100 cutoff_altitude_km: f64,
101 ) -> Result<Self, PropagationError> {
102 DragParameters::from_area_mass(cd, area_m2, mass_kg, sw, cutoff_altitude_km)
103 .map(DragParameters::to_force)
104 }
105
106 pub fn from_bc_factor_m2_kg(
108 bc_factor_m2_kg: f64,
109 sw: SpaceWeather,
110 cutoff_altitude_km: f64,
111 ) -> Result<Self, PropagationError> {
112 DragParameters::from_bc_factor_m2_kg(bc_factor_m2_kg, sw, cutoff_altitude_km)
113 .map(DragParameters::to_force)
114 }
115
116 pub fn from_ballistic_coefficient(
118 bc_kg_m2: f64,
119 sw: SpaceWeather,
120 cutoff_altitude_km: f64,
121 ) -> Result<Self, PropagationError> {
122 DragParameters::from_ballistic_coefficient(bc_kg_m2, sw, cutoff_altitude_km)
123 .map(DragParameters::to_force)
124 }
125
126 pub fn from_area_mass_default_cutoff(
128 cd: f64,
129 area_m2: f64,
130 mass_kg: f64,
131 sw: SpaceWeather,
132 ) -> Result<Self, PropagationError> {
133 DragParameters::from_area_mass_default_cutoff(cd, area_m2, mass_kg, sw)
134 .map(DragParameters::to_force)
135 }
136
137 pub fn bc_factor_m2_kg(&self) -> f64 {
139 self.bc_factor_m2_kg
140 }
141
142 pub fn space_weather(&self) -> SpaceWeather {
144 self.space_weather
145 }
146
147 pub fn cutoff_altitude_km(&self) -> f64 {
149 self.cutoff_altitude_km
150 }
151}
152
153impl ForceModel for DragForce {
154 fn acceleration(
155 &self,
156 state: &CartesianState,
157 _ctx: &PropagationContext,
158 ) -> Result<Vector3<f64>, PropagationError> {
159 drag_acceleration(
160 self.space_weather,
161 self.bc_factor_m2_kg,
162 self.cutoff_altitude_km,
163 state,
164 )
165 }
166}
167
168#[derive(Debug, Clone)]
170pub struct SourcedDragForce {
171 bc_factor_m2_kg: f64,
172 source: SpaceWeatherSource,
173 cutoff_altitude_km: f64,
174}
175
176impl SourcedDragForce {
177 pub fn new(drag: DragParameters, source: SpaceWeatherSource) -> Self {
182 Self {
183 bc_factor_m2_kg: drag.bc_factor_m2_kg,
184 source,
185 cutoff_altitude_km: drag.cutoff_altitude_km,
186 }
187 }
188
189 pub fn bc_factor_m2_kg(&self) -> f64 {
191 self.bc_factor_m2_kg
192 }
193
194 pub fn source(&self) -> &SpaceWeatherSource {
196 &self.source
197 }
198
199 pub fn cutoff_altitude_km(&self) -> f64 {
201 self.cutoff_altitude_km
202 }
203}
204
205impl ForceModel for SourcedDragForce {
206 fn acceleration(
207 &self,
208 state: &CartesianState,
209 _ctx: &PropagationContext,
210 ) -> Result<Vector3<f64>, PropagationError> {
211 let space_weather = self.source.at(state.epoch_tdb_seconds).map_err(|error| {
212 PropagationError::ForceModelFailure(format!("space weather: {error}"))
213 })?;
214 drag_acceleration(
215 space_weather,
216 self.bc_factor_m2_kg,
217 self.cutoff_altitude_km,
218 state,
219 )
220 }
221}
222
223#[derive(Debug, Clone, Copy, PartialEq)]
225pub struct DragParameters {
226 bc_factor_m2_kg: f64,
227 space_weather: SpaceWeather,
228 cutoff_altitude_km: f64,
229}
230
231impl DragParameters {
232 pub fn from_area_mass(
234 cd: f64,
235 area_m2: f64,
236 mass_kg: f64,
237 sw: SpaceWeather,
238 cutoff_altitude_km: f64,
239 ) -> Result<Self, PropagationError> {
240 validate_finite_positive("cd", cd)?;
241 validate_finite_positive("area_m2", area_m2)?;
242 validate_finite_positive("mass_kg", mass_kg)?;
243 let bc_factor_m2_kg = cd * area_m2 / mass_kg;
244 Self::from_bc_factor_m2_kg(bc_factor_m2_kg, sw, cutoff_altitude_km)
245 }
246
247 pub fn from_bc_factor_m2_kg(
249 bc_factor_m2_kg: f64,
250 sw: SpaceWeather,
251 cutoff_altitude_km: f64,
252 ) -> Result<Self, PropagationError> {
253 validate_finite_positive("bc_factor_m2_kg", bc_factor_m2_kg)?;
254 validate_space_weather(sw)?;
255 validate_cutoff(cutoff_altitude_km)?;
256 Ok(Self {
257 bc_factor_m2_kg,
258 space_weather: sw,
259 cutoff_altitude_km,
260 })
261 }
262
263 pub fn from_ballistic_coefficient(
265 bc_kg_m2: f64,
266 sw: SpaceWeather,
267 cutoff_altitude_km: f64,
268 ) -> Result<Self, PropagationError> {
269 validate_finite_positive("bc_kg_m2", bc_kg_m2)?;
270 Self::from_bc_factor_m2_kg(bc_kg_m2.recip(), sw, cutoff_altitude_km)
271 }
272
273 pub fn from_area_mass_default_cutoff(
275 cd: f64,
276 area_m2: f64,
277 mass_kg: f64,
278 sw: SpaceWeather,
279 ) -> Result<Self, PropagationError> {
280 Self::from_area_mass(
281 cd,
282 area_m2,
283 mass_kg,
284 sw,
285 DragForce::DEFAULT_REENTRY_ALTITUDE_KM,
286 )
287 }
288
289 pub fn to_force(self) -> DragForce {
291 DragForce {
292 bc_factor_m2_kg: self.bc_factor_m2_kg,
293 space_weather: self.space_weather,
294 cutoff_altitude_km: self.cutoff_altitude_km,
295 }
296 }
297
298 pub fn bc_factor_m2_kg(&self) -> f64 {
300 self.bc_factor_m2_kg
301 }
302
303 pub fn space_weather(&self) -> SpaceWeather {
305 self.space_weather
306 }
307
308 pub fn cutoff_altitude_km(&self) -> f64 {
310 self.cutoff_altitude_km
311 }
312}
313
314pub(crate) fn geodetic_altitude_km(state: &CartesianState) -> Result<f64, PropagationError> {
315 validate_drag_state(state)?;
316 Ok(geodetic_from_validated_state(state)?.alt_km)
317}
318
319pub(crate) fn map_frame_error(
320 context: &'static str,
321 error: FrameTransformError,
322) -> PropagationError {
323 PropagationError::NumericalFailure(format!("drag {context} failed: {error}"))
324}
325
326fn drag_acceleration(
327 space_weather: SpaceWeather,
328 bc_factor_m2_kg: f64,
329 cutoff_altitude_km: f64,
330 state: &CartesianState,
331) -> Result<Vector3<f64>, PropagationError> {
332 validate_drag_state(state)?;
333 let calendar = calendar_from_epoch(state.epoch_tdb_seconds);
334 let geodetic = geodetic_from_validated_state(state)?;
335
336 if geodetic.alt_km <= cutoff_altitude_km || geodetic.alt_km > MAX_ALTITUDE_KM {
337 return Ok(Vector3::zeros());
338 }
339
340 let input = NrlmsiseInput {
341 year: calendar.year,
342 doy: calendar.doy,
343 sec: calendar.sec_of_day,
344 alt: geodetic.alt_km,
345 g_lat: geodetic.lat_deg,
346 g_long: geodetic.lon_deg,
347 lst: 0.0,
348 f107a: space_weather.f107a,
349 f107: space_weather.f107,
350 ap: space_weather.ap,
351 ap_array: None,
352 };
353 let density = atmosphere::nrlmsise00_with_lst(&input, None)
354 .map_err(|error| {
355 PropagationError::NumericalFailure(format!("drag atmosphere failed: {error}"))
356 })?
357 .density();
358 if !density.is_finite() {
359 return Err(PropagationError::NumericalFailure(
360 "drag density not finite".to_string(),
361 ));
362 }
363
364 let v_rel_km_s = relative_velocity_km_s(state);
365 if !vector_is_finite(&v_rel_km_s) {
366 return Err(PropagationError::NumericalFailure(
367 "drag relative velocity not finite".to_string(),
368 ));
369 }
370 let v_rel_m_s = v_rel_km_s * M_PER_KM;
371 let speed_m_s = v_rel_m_s.norm();
372 if !speed_m_s.is_finite() {
373 return Err(PropagationError::NumericalFailure(
374 "drag relative speed not finite".to_string(),
375 ));
376 }
377
378 let accel_m_s2 = v_rel_m_s * (-0.5 * density * bc_factor_m2_kg * speed_m_s);
379 let accel_km_s2 = accel_m_s2 / M_PER_KM;
380 if !vector_is_finite(&accel_km_s2) {
381 return Err(PropagationError::NumericalFailure(
382 "drag acceleration not finite".to_string(),
383 ));
384 }
385 Ok(accel_km_s2)
386}
387
388fn validate_finite_positive(field: &'static str, value: f64) -> Result<(), PropagationError> {
389 if !value.is_finite() {
390 return Err(PropagationError::InvalidInput(format!(
391 "{field} not finite"
392 )));
393 }
394 if value <= 0.0 {
395 return Err(PropagationError::InvalidInput(format!(
396 "{field} not positive"
397 )));
398 }
399 Ok(())
400}
401
402fn validate_finite_nonnegative(field: &'static str, value: f64) -> Result<(), PropagationError> {
403 if !value.is_finite() {
404 return Err(PropagationError::InvalidInput(format!(
405 "{field} not finite"
406 )));
407 }
408 if value < 0.0 {
409 return Err(PropagationError::InvalidInput(format!("{field} negative")));
410 }
411 Ok(())
412}
413
414fn validate_space_weather(sw: SpaceWeather) -> Result<(), PropagationError> {
415 validate_finite_nonnegative("f107", sw.f107)?;
416 validate_finite_nonnegative("f107a", sw.f107a)?;
417 validate_finite_nonnegative("ap", sw.ap)
418}
419
420fn validate_cutoff(cutoff_altitude_km: f64) -> Result<(), PropagationError> {
421 if !cutoff_altitude_km.is_finite() {
422 return Err(PropagationError::InvalidInput(
423 "cutoff_altitude_km not finite".to_string(),
424 ));
425 }
426 if !(0.0..=MAX_ALTITUDE_KM).contains(&cutoff_altitude_km) {
427 return Err(PropagationError::InvalidInput(
428 "cutoff_altitude_km out of domain".to_string(),
429 ));
430 }
431 Ok(())
432}
433
434fn validate_drag_state(state: &CartesianState) -> Result<(), PropagationError> {
435 if !state.epoch_tdb_seconds.is_finite() {
436 return Err(PropagationError::InvalidInput(
437 "epoch_tdb_seconds not finite".to_string(),
438 ));
439 }
440 if state.epoch_tdb_seconds.abs() > MAX_EPOCH_OFFSET_S {
441 return Err(PropagationError::InvalidInput(
442 "epoch_tdb_seconds outside +/-1000 Julian years from J2000".to_string(),
443 ));
444 }
445 if !vector_is_finite(&state.position_km) {
446 return Err(PropagationError::InvalidInput(
447 "position_km not finite".to_string(),
448 ));
449 }
450 if !vector_is_finite(&state.velocity_km_s) {
451 return Err(PropagationError::InvalidInput(
452 "velocity_km_s not finite".to_string(),
453 ));
454 }
455 if state.position_km.norm_squared() == 0.0 {
456 return Err(PropagationError::NumericalFailure(
457 "Zero position magnitude".to_string(),
458 ));
459 }
460 Ok(())
461}
462
463fn vector_is_finite(v: &Vector3<f64>) -> bool {
464 v.x.is_finite() && v.y.is_finite() && v.z.is_finite()
465}
466
467#[derive(Debug, Clone, Copy)]
468struct DragCalendar {
469 year: i32,
470 doy: i32,
471 sec_of_day: f64,
472}
473
474fn calendar_from_epoch(epoch_tdb_seconds: f64) -> DragCalendar {
475 let sec_i = epoch_tdb_seconds.floor() as i64;
476 let (year, month, day, hour, minute, second) = civil_from_j2000_seconds(sec_i);
477 let sec_of_day = second_of_day(hour as i32, minute as i32, second as f64)
478 + (epoch_tdb_seconds - sec_i as f64);
479 DragCalendar {
480 year: year as i32,
481 doy: day_of_year_int(year as i32, month as i32, day as i32) as i32,
482 sec_of_day,
483 }
484}
485
486#[derive(Debug, Clone, Copy)]
487struct DragGeodetic {
488 lat_deg: f64,
489 lon_deg: f64,
490 alt_km: f64,
491}
492
493fn geodetic_from_validated_state(state: &CartesianState) -> Result<DragGeodetic, PropagationError> {
494 let theta = greenwich_mean_sidereal_time_radians_from_j2000_seconds(state.epoch_tdb_seconds)
495 .map_err(|error| map_frame_error("gmst", error))?;
496 let r_ecef = eci_to_ecef_gmst(state.position_km, theta);
497 if !vector_is_finite(&r_ecef) {
498 return Err(PropagationError::NumericalFailure(
499 "drag ECEF position not finite".to_string(),
500 ));
501 }
502 let (lat_deg, lon_deg, alt_km) = itrs_to_geodetic_compute(r_ecef.x, r_ecef.y, r_ecef.z)
503 .map_err(|error| map_frame_error("geodetic", error))?;
504 if !(lat_deg.is_finite() && lon_deg.is_finite() && alt_km.is_finite()) {
505 return Err(PropagationError::NumericalFailure(
506 "drag geodetic state not finite".to_string(),
507 ));
508 }
509 Ok(DragGeodetic {
510 lat_deg,
511 lon_deg,
512 alt_km,
513 })
514}
515
516fn eci_to_ecef_gmst(position_km: Vector3<f64>, theta: f64) -> Vector3<f64> {
517 let c = theta.cos();
518 let s = theta.sin();
519 Vector3::new(
520 c * position_km.x + s * position_km.y,
521 -s * position_km.x + c * position_km.y,
522 position_km.z,
523 )
524}
525
526fn relative_velocity_km_s(state: &CartesianState) -> Vector3<f64> {
527 let omega_cross_r = Vector3::new(
528 -OMEGA_E_DOT_RAD_S * state.position_km.y,
529 OMEGA_E_DOT_RAD_S * state.position_km.x,
530 0.0,
531 );
532 state.velocity_km_s - omega_cross_r
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use crate::astro::constants::{earth::GM_EARTH_M3_S2, MU_EARTH, RE_EARTH};
539 use crate::astro::elements::rv2coe;
540 use crate::astro::frames::transforms::geodetic_to_itrs;
541 use crate::astro::propagator::api::IntegratorOptions;
542 use crate::astro::propagator::numerical::{ForceModelKind, IntegratorKind, StatePropagator};
543 use std::f64::consts::TAU;
544
545 const TEST_EPOCH_S: f64 = 646_315_200.25;
546 const BC_FACTOR: f64 = 0.02;
547
548 fn quiet_sw() -> SpaceWeather {
549 SpaceWeather::default()
550 }
551
552 fn test_drag(bc_factor_m2_kg: f64) -> DragForce {
553 DragForce::from_bc_factor_m2_kg(
554 bc_factor_m2_kg,
555 quiet_sw(),
556 DragForce::DEFAULT_REENTRY_ALTITUDE_KM,
557 )
558 .expect("valid drag")
559 }
560
561 fn test_drag_parameters(bc_factor_m2_kg: f64) -> DragParameters {
562 DragParameters::from_bc_factor_m2_kg(
563 bc_factor_m2_kg,
564 quiet_sw(),
565 DragForce::DEFAULT_REENTRY_ALTITUDE_KM,
566 )
567 .expect("valid drag")
568 }
569
570 fn circular_state(epoch: f64, altitude_km: f64, inclination_rad: f64) -> CartesianState {
571 let r = RE_EARTH + altitude_km;
572 let v = (MU_EARTH / r).sqrt();
573 CartesianState::new(
574 epoch,
575 [r, 0.0, 0.0],
576 [0.0, v * inclination_rad.cos(), v * inclination_rad.sin()],
577 )
578 }
579
580 fn circular_state_at_argument(
581 epoch: f64,
582 altitude_km: f64,
583 inclination_rad: f64,
584 argument_rad: f64,
585 ) -> CartesianState {
586 let r = RE_EARTH + altitude_km;
587 let v = (MU_EARTH / r).sqrt();
588 let cu = argument_rad.cos();
589 let su = argument_rad.sin();
590 let ci = inclination_rad.cos();
591 let si = inclination_rad.sin();
592 CartesianState::new(
593 epoch,
594 [r * cu, r * su * ci, r * su * si],
595 [-v * su, v * cu * ci, v * cu * si],
596 )
597 }
598
599 fn state_from_geodetic_alt(epoch: f64, altitude_km: f64) -> CartesianState {
600 let (x_ecef, y_ecef, z_ecef) =
601 geodetic_to_itrs(0.0, 0.0, altitude_km).expect("valid geodetic");
602 let theta =
603 greenwich_mean_sidereal_time_radians_from_j2000_seconds(epoch).expect("valid gmst");
604 let c = theta.cos();
605 let s = theta.sin();
606 let x_eci = c * x_ecef - s * y_ecef;
607 let y_eci = s * x_ecef + c * y_ecef;
608 let r = RE_EARTH + altitude_km;
609 let v = (MU_EARTH / r).sqrt();
610 CartesianState::new(epoch, [x_eci, y_eci, z_ecef], [0.0, v, 0.0])
611 }
612
613 fn density_for_state(state: &CartesianState, sw: SpaceWeather) -> f64 {
614 validate_drag_state(state).expect("valid drag state");
615 let calendar = calendar_from_epoch(state.epoch_tdb_seconds);
616 let geodetic = geodetic_from_validated_state(state).expect("valid geodetic");
617 let input = NrlmsiseInput {
618 year: calendar.year,
619 doy: calendar.doy,
620 sec: calendar.sec_of_day,
621 alt: geodetic.alt_km,
622 g_lat: geodetic.lat_deg,
623 g_long: geodetic.lon_deg,
624 lst: 0.0,
625 f107a: sw.f107a,
626 f107: sw.f107,
627 ap: sw.ap,
628 ap_array: None,
629 };
630 atmosphere::nrlmsise00_with_lst(&input, None)
631 .expect("valid atmosphere")
632 .density()
633 }
634
635 fn hand_acceleration(state: &CartesianState, bc_factor_m2_kg: f64) -> Vector3<f64> {
636 let rho = density_for_state(state, quiet_sw());
637 let v_rel_m_s = relative_velocity_km_s(state) * M_PER_KM;
638 v_rel_m_s * (-0.5 * rho * bc_factor_m2_kg * v_rel_m_s.norm()) / M_PER_KM
639 }
640
641 fn specific_energy(state: &CartesianState) -> f64 {
642 0.5 * state.velocity_km_s.norm_squared() - MU_EARTH / state.position_km.norm()
643 }
644
645 fn sma_km(state: &CartesianState) -> f64 {
646 rv2coe(state.position_array(), state.velocity_array(), MU_EARTH)
647 .expect("valid elements")
648 .a
649 }
650
651 fn slope(xs: &[f64], ys: &[f64]) -> f64 {
652 let n = xs.len() as f64;
653 let mean_x = xs.iter().sum::<f64>() / n;
654 let mean_y = ys.iter().sum::<f64>() / n;
655 let mut numerator = 0.0;
656 let mut denominator = 0.0;
657 for (&x, &y) in xs.iter().zip(ys.iter()) {
658 numerator += (x - mean_x) * (y - mean_y);
659 denominator += (x - mean_x) * (x - mean_x);
660 }
661 numerator / denominator
662 }
663
664 fn propagation_options() -> IntegratorOptions {
665 IntegratorOptions {
666 abs_tol: 1.0e-9,
667 rel_tol: 1.0e-11,
668 initial_step: 30.0,
669 min_step: 1.0e-6,
670 max_step: 120.0,
671 max_steps: 200_000,
672 dense_output: false,
673 }
674 }
675
676 #[test]
677 fn drag_force_matches_hand_computed_acceleration_0ulp() {
678 let state = circular_state(TEST_EPOCH_S, 400.0, 51.6_f64.to_radians());
679 let drag = test_drag(BC_FACTOR);
680 let actual = drag
681 .acceleration(&state, &PropagationContext::default())
682 .expect("drag acceleration");
683 let expected = hand_acceleration(&state, BC_FACTOR);
684
685 assert_eq!(actual.x.to_bits(), expected.x.to_bits());
686 assert_eq!(actual.y.to_bits(), expected.y.to_bits());
687 assert_eq!(actual.z.to_bits(), expected.z.to_bits());
688 }
689
690 #[test]
691 fn drag_is_antiparallel_to_relative_velocity() {
692 const DIRECTION_TOL: f64 = 1.0e-14;
693 let state = circular_state(TEST_EPOCH_S, 380.0, 63.4_f64.to_radians());
694 let drag = test_drag(BC_FACTOR);
695 let accel = drag
696 .acceleration(&state, &PropagationContext::default())
697 .expect("drag acceleration");
698 let v_rel = relative_velocity_km_s(&state);
699
700 assert!(accel.dot(&v_rel) < 0.0);
701 assert!(accel.cross(&v_rel).norm() <= accel.norm() * v_rel.norm() * DIRECTION_TOL);
702
703 let high_density = density_for_state(&circular_state(TEST_EPOCH_S, 300.0, 0.0), quiet_sw());
704 let low_density = density_for_state(&circular_state(TEST_EPOCH_S, 450.0, 0.0), quiet_sw());
705 assert!(high_density > low_density);
706 }
707
708 #[test]
709 fn drag_scales_linearly_with_bc() {
710 const LINEAR_TOL: f64 = 1.0e-14;
711 let state = circular_state(TEST_EPOCH_S, 400.0, 0.3);
712 let accel = test_drag(BC_FACTOR)
713 .acceleration(&state, &PropagationContext::default())
714 .expect("drag acceleration");
715 let doubled = test_drag(2.0 * BC_FACTOR)
716 .acceleration(&state, &PropagationContext::default())
717 .expect("drag acceleration");
718
719 assert!((doubled - accel * 2.0).norm() <= accel.norm() * LINEAR_TOL);
720 }
721
722 #[test]
723 fn rotating_atmosphere_reduces_drag_vs_inertial() {
724 let state = circular_state(TEST_EPOCH_S, 400.0, 0.0);
725 let drag = test_drag(BC_FACTOR);
726 let rotating = drag
727 .acceleration(&state, &PropagationContext::default())
728 .expect("drag acceleration");
729 let rho = density_for_state(&state, quiet_sw());
730 let v_eci_m_s = state.velocity_km_s * M_PER_KM;
731 let inertial = v_eci_m_s * (-0.5 * rho * BC_FACTOR * v_eci_m_s.norm()) / M_PER_KM;
732
733 assert!(rotating.norm() < inertial.norm());
734 }
735
736 #[test]
737 fn drag_secularly_decreases_energy_and_sma() {
738 const ENERGY_DROP_TOL: f64 = 1.0e-5;
739 const SMA_DROP_TOL_KM: f64 = 1.0e-3;
740 let initial = circular_state(TEST_EPOCH_S, 250.0, 70.0_f64.to_radians());
741 let drag = test_drag_parameters(0.15);
742 let propagator = StatePropagator::new(
743 initial.epoch_tdb_seconds,
744 initial.position_array(),
745 initial.velocity_array(),
746 ForceModelKind::two_body(),
747 IntegratorKind::Dp54,
748 )
749 .with_options(propagation_options())
750 .with_drag(drag);
751 let epochs: Vec<f64> = (0..=12)
752 .map(|i| initial.epoch_tdb_seconds + i as f64 * 600.0)
753 .collect();
754 let states = propagator.ephemeris(&epochs).expect("drag ephemeris");
755 let elapsed: Vec<f64> = states
756 .iter()
757 .map(|state| state.epoch_tdb_seconds - initial.epoch_tdb_seconds)
758 .collect();
759 let energies: Vec<f64> = states.iter().map(specific_energy).collect();
760 let smas: Vec<f64> = states.iter().map(sma_km).collect();
761
762 assert!(energies[energies.len() - 1] < energies[0] - ENERGY_DROP_TOL);
763 assert!(smas[smas.len() - 1] < smas[0] - SMA_DROP_TOL_KM);
764 assert!(slope(&elapsed, &energies) < 0.0);
765 assert!(slope(&elapsed, &smas) < 0.0);
766 }
767
768 #[test]
769 fn no_drag_conserves_energy() {
770 const ENERGY_TOL: f64 = 1.0e-8;
771 const SMA_TOL_KM: f64 = 1.0e-5;
772 let initial = circular_state(TEST_EPOCH_S, 250.0, 70.0_f64.to_radians());
773 let propagator = StatePropagator::new(
774 initial.epoch_tdb_seconds,
775 initial.position_array(),
776 initial.velocity_array(),
777 ForceModelKind::two_body(),
778 IntegratorKind::Dp54,
779 )
780 .with_options(IntegratorOptions {
781 abs_tol: 1.0e-11,
782 rel_tol: 1.0e-13,
783 ..propagation_options()
784 });
785 let epochs: Vec<f64> = (0..=12)
786 .map(|i| initial.epoch_tdb_seconds + i as f64 * 600.0)
787 .collect();
788 let states = propagator.ephemeris(&epochs).expect("no-drag ephemeris");
789 let energies: Vec<f64> = states.iter().map(specific_energy).collect();
790 let smas: Vec<f64> = states.iter().map(sma_km).collect();
791
792 assert!((energies[energies.len() - 1] - energies[0]).abs() <= ENERGY_TOL);
793 assert!((smas[smas.len() - 1] - smas[0]).abs() <= SMA_TOL_KM);
794 }
795
796 #[test]
797 fn near_circular_leo_decay_rate_matches_kinghele_within_tolerance() {
798 const AGREEMENT_TOL: f64 = 0.15;
804 let altitude_km = 360.0;
805 let inclination_rad = 88.0_f64.to_radians();
806 let radius_km = RE_EARTH + altitude_km;
807 let period_s = TAU * (radius_km.powi(3) / MU_EARTH).sqrt();
808 let duration_s = 3.0 * period_s;
809 let initial = circular_state(TEST_EPOCH_S, altitude_km, inclination_rad);
810 let drag = test_drag_parameters(BC_FACTOR);
811
812 let propagator = StatePropagator::new(
813 initial.epoch_tdb_seconds,
814 initial.position_array(),
815 initial.velocity_array(),
816 ForceModelKind::two_body(),
817 IntegratorKind::Dp54,
818 )
819 .with_options(propagation_options())
820 .with_drag(drag);
821 let epochs: Vec<f64> = (0..=18)
822 .map(|i| initial.epoch_tdb_seconds + duration_s * i as f64 / 18.0)
823 .collect();
824 let states = propagator.ephemeris(&epochs).expect("drag ephemeris");
825 let elapsed: Vec<f64> = states
826 .iter()
827 .map(|state| state.epoch_tdb_seconds - initial.epoch_tdb_seconds)
828 .collect();
829 let sma_m: Vec<f64> = states
830 .iter()
831 .map(|state| sma_km(state) * M_PER_KM)
832 .collect();
833 let observed_rate_m_s = slope(&elapsed, &sma_m);
834
835 let samples = 24;
836 let mut density_sum = 0.0;
837 for i in 0..samples {
838 let fraction = i as f64 / samples as f64;
839 let state = circular_state_at_argument(
840 TEST_EPOCH_S + period_s * fraction,
841 altitude_km,
842 inclination_rad,
843 TAU * fraction,
844 );
845 density_sum += density_for_state(&state, quiet_sw());
846 }
847 let mean_density = density_sum / samples as f64;
848 let expected_rate_m_s =
849 -BC_FACTOR * mean_density * (GM_EARTH_M3_S2 * radius_km * M_PER_KM).sqrt();
850
851 let relative_error = ((observed_rate_m_s - expected_rate_m_s) / expected_rate_m_s).abs();
852 assert!(
853 relative_error <= AGREEMENT_TOL,
854 "observed {observed_rate_m_s} m/s expected {expected_rate_m_s} m/s"
855 );
856 }
857
858 #[test]
859 fn drag_zero_above_ceiling_and_below_cutoff() {
860 let at_ceiling = state_from_geodetic_alt(TEST_EPOCH_S, MAX_ALTITUDE_KM);
861 let above_ceiling = state_from_geodetic_alt(TEST_EPOCH_S, MAX_ALTITUDE_KM + 1.0e-3);
862 let accel_ceiling = test_drag(BC_FACTOR)
863 .acceleration(&at_ceiling, &PropagationContext::default())
864 .expect("ceiling evaluates");
865 let accel_above = test_drag(BC_FACTOR)
866 .acceleration(&above_ceiling, &PropagationContext::default())
867 .expect("above ceiling zeroes");
868 assert!(accel_ceiling.norm() > 0.0);
869 assert_eq!(accel_above, Vector3::zeros());
870
871 let cutoff_state = state_from_geodetic_alt(TEST_EPOCH_S, 100.0);
872 let cutoff = geodetic_altitude_km(&cutoff_state).expect("cutoff altitude");
873 let drag =
874 DragForce::from_bc_factor_m2_kg(BC_FACTOR, quiet_sw(), cutoff).expect("valid cutoff");
875 let accel_cutoff = drag
876 .acceleration(&cutoff_state, &PropagationContext::default())
877 .expect("cutoff zeroes");
878 assert_eq!(accel_cutoff, Vector3::zeros());
879
880 let above_cutoff = state_from_geodetic_alt(TEST_EPOCH_S, 100.001);
881 let accel_above_cutoff = drag
882 .acceleration(&above_cutoff, &PropagationContext::default())
883 .expect("above cutoff evaluates");
884 assert!(accel_above_cutoff.norm() > 0.0);
885 }
886
887 #[test]
888 fn constructors_reject_invalid_parameters() {
889 let invalid_sw = SpaceWeather {
890 f107: -1.0,
891 ..SpaceWeather::default()
892 };
893 let cases = [
894 DragParameters::from_area_mass(2.2, 1.0, -1.0, quiet_sw(), 100.0),
895 DragParameters::from_bc_factor_m2_kg(-1.0, quiet_sw(), 100.0),
896 DragParameters::from_bc_factor_m2_kg(BC_FACTOR, invalid_sw, 100.0),
897 DragParameters::from_bc_factor_m2_kg(BC_FACTOR, quiet_sw(), -1.0),
898 ];
899
900 for case in cases {
901 assert!(matches!(case, Err(PropagationError::InvalidInput(_))));
902 }
903 }
904
905 #[test]
906 fn zero_position_is_numerical_failure() {
907 let drag = test_drag(BC_FACTOR);
908 let state = CartesianState::new(TEST_EPOCH_S, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]);
909 let error = drag
910 .acceleration(&state, &PropagationContext::default())
911 .expect_err("zero position fails");
912 assert!(matches!(error, PropagationError::NumericalFailure(_)));
913
914 let mapped = map_frame_error(
915 "geodetic",
916 FrameTransformError::InvalidInput {
917 field: "itrs_position_km",
918 reason: "components must be finite",
919 },
920 );
921 match mapped {
922 PropagationError::NumericalFailure(message) => {
923 assert!(message.contains("drag geodetic"), "{message}");
924 }
925 other => panic!("expected numerical failure, got {other:?}"),
926 }
927 }
928
929 #[test]
930 fn drag_rotation_sign_matches_known_longitude() {
931 const LON_TOL_DEG: f64 = 1.0e-12;
932 let theta = greenwich_mean_sidereal_time_radians_from_j2000_seconds(TEST_EPOCH_S)
933 .expect("valid gmst");
934 let state = CartesianState::new(TEST_EPOCH_S, [RE_EARTH + 400.0, 0.0, 0.0], [0.0; 3]);
935 let geodetic = geodetic_from_validated_state(&state).expect("valid geodetic");
936 let expected_lon = (-theta).to_degrees();
937 let expected_lon = ((expected_lon + 180.0).rem_euclid(360.0)) - 180.0;
938
939 assert!((geodetic.lon_deg - expected_lon).abs() <= LON_TOL_DEG);
940 }
941
942 #[test]
943 fn drag_golden_case_bits() {
944 let state = circular_state(TEST_EPOCH_S, 400.0, 51.6_f64.to_radians());
954 let accel = test_drag(BC_FACTOR)
955 .acceleration(&state, &PropagationContext::default())
956 .expect("drag acceleration");
957
958 assert_eq!(
959 [accel.x.to_bits(), accel.y.to_bits(), accel.z.to_bits()],
960 [
961 9_223_372_036_854_775_808,
962 13_692_397_580_950_677_423,
963 13_694_827_167_186_369_315,
964 ]
965 );
966 }
967}