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 = libm::cos(theta);
518 let s = libm::sin(theta);
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 [
577 0.0,
578 v * libm::cos(inclination_rad),
579 v * libm::sin(inclination_rad),
580 ],
581 )
582 }
583
584 fn circular_state_at_argument(
585 epoch: f64,
586 altitude_km: f64,
587 inclination_rad: f64,
588 argument_rad: f64,
589 ) -> CartesianState {
590 let r = RE_EARTH + altitude_km;
591 let v = (MU_EARTH / r).sqrt();
592 let cu = libm::cos(argument_rad);
593 let su = libm::sin(argument_rad);
594 let ci = libm::cos(inclination_rad);
595 let si = libm::sin(inclination_rad);
596 CartesianState::new(
597 epoch,
598 [r * cu, r * su * ci, r * su * si],
599 [-v * su, v * cu * ci, v * cu * si],
600 )
601 }
602
603 fn state_from_geodetic_alt(epoch: f64, altitude_km: f64) -> CartesianState {
604 let (x_ecef, y_ecef, z_ecef) =
605 geodetic_to_itrs(0.0, 0.0, altitude_km).expect("valid geodetic");
606 let theta =
607 greenwich_mean_sidereal_time_radians_from_j2000_seconds(epoch).expect("valid gmst");
608 let c = libm::cos(theta);
609 let s = libm::sin(theta);
610 let x_eci = c * x_ecef - s * y_ecef;
611 let y_eci = s * x_ecef + c * y_ecef;
612 let r = RE_EARTH + altitude_km;
613 let v = (MU_EARTH / r).sqrt();
614 CartesianState::new(epoch, [x_eci, y_eci, z_ecef], [0.0, v, 0.0])
615 }
616
617 fn density_for_state(state: &CartesianState, sw: SpaceWeather) -> f64 {
618 validate_drag_state(state).expect("valid drag state");
619 let calendar = calendar_from_epoch(state.epoch_tdb_seconds);
620 let geodetic = geodetic_from_validated_state(state).expect("valid geodetic");
621 let input = NrlmsiseInput {
622 year: calendar.year,
623 doy: calendar.doy,
624 sec: calendar.sec_of_day,
625 alt: geodetic.alt_km,
626 g_lat: geodetic.lat_deg,
627 g_long: geodetic.lon_deg,
628 lst: 0.0,
629 f107a: sw.f107a,
630 f107: sw.f107,
631 ap: sw.ap,
632 ap_array: None,
633 };
634 atmosphere::nrlmsise00_with_lst(&input, None)
635 .expect("valid atmosphere")
636 .density()
637 }
638
639 fn hand_acceleration(state: &CartesianState, bc_factor_m2_kg: f64) -> Vector3<f64> {
640 let rho = density_for_state(state, quiet_sw());
641 let v_rel_m_s = relative_velocity_km_s(state) * M_PER_KM;
642 v_rel_m_s * (-0.5 * rho * bc_factor_m2_kg * v_rel_m_s.norm()) / M_PER_KM
643 }
644
645 fn specific_energy(state: &CartesianState) -> f64 {
646 0.5 * state.velocity_km_s.norm_squared() - MU_EARTH / state.position_km.norm()
647 }
648
649 fn sma_km(state: &CartesianState) -> f64 {
650 rv2coe(state.position_array(), state.velocity_array(), MU_EARTH)
651 .expect("valid elements")
652 .a
653 }
654
655 fn slope(xs: &[f64], ys: &[f64]) -> f64 {
656 let n = xs.len() as f64;
657 let mean_x = xs.iter().sum::<f64>() / n;
658 let mean_y = ys.iter().sum::<f64>() / n;
659 let mut numerator = 0.0;
660 let mut denominator = 0.0;
661 for (&x, &y) in xs.iter().zip(ys.iter()) {
662 numerator += (x - mean_x) * (y - mean_y);
663 denominator += (x - mean_x) * (x - mean_x);
664 }
665 numerator / denominator
666 }
667
668 fn propagation_options() -> IntegratorOptions {
669 IntegratorOptions {
670 abs_tol: 1.0e-9,
671 rel_tol: 1.0e-11,
672 initial_step: 30.0,
673 min_step: 1.0e-6,
674 max_step: 120.0,
675 max_steps: 200_000,
676 dense_output: false,
677 }
678 }
679
680 #[test]
681 fn drag_force_matches_hand_computed_acceleration_0ulp() {
682 let state = circular_state(TEST_EPOCH_S, 400.0, 51.6_f64.to_radians());
683 let drag = test_drag(BC_FACTOR);
684 let actual = drag
685 .acceleration(&state, &PropagationContext::default())
686 .expect("drag acceleration");
687 let expected = hand_acceleration(&state, BC_FACTOR);
688
689 assert_eq!(actual.x.to_bits(), expected.x.to_bits());
690 assert_eq!(actual.y.to_bits(), expected.y.to_bits());
691 assert_eq!(actual.z.to_bits(), expected.z.to_bits());
692 }
693
694 #[test]
695 fn drag_is_antiparallel_to_relative_velocity() {
696 const DIRECTION_TOL: f64 = 1.0e-14;
697 let state = circular_state(TEST_EPOCH_S, 380.0, 63.4_f64.to_radians());
698 let drag = test_drag(BC_FACTOR);
699 let accel = drag
700 .acceleration(&state, &PropagationContext::default())
701 .expect("drag acceleration");
702 let v_rel = relative_velocity_km_s(&state);
703
704 assert!(accel.dot(&v_rel) < 0.0);
705 assert!(accel.cross(&v_rel).norm() <= accel.norm() * v_rel.norm() * DIRECTION_TOL);
706
707 let high_density = density_for_state(&circular_state(TEST_EPOCH_S, 300.0, 0.0), quiet_sw());
708 let low_density = density_for_state(&circular_state(TEST_EPOCH_S, 450.0, 0.0), quiet_sw());
709 assert!(high_density > low_density);
710 }
711
712 #[test]
713 fn drag_scales_linearly_with_bc() {
714 const LINEAR_TOL: f64 = 1.0e-14;
715 let state = circular_state(TEST_EPOCH_S, 400.0, 0.3);
716 let accel = test_drag(BC_FACTOR)
717 .acceleration(&state, &PropagationContext::default())
718 .expect("drag acceleration");
719 let doubled = test_drag(2.0 * BC_FACTOR)
720 .acceleration(&state, &PropagationContext::default())
721 .expect("drag acceleration");
722
723 assert!((doubled - accel * 2.0).norm() <= accel.norm() * LINEAR_TOL);
724 }
725
726 #[test]
727 fn rotating_atmosphere_reduces_drag_vs_inertial() {
728 let state = circular_state(TEST_EPOCH_S, 400.0, 0.0);
729 let drag = test_drag(BC_FACTOR);
730 let rotating = drag
731 .acceleration(&state, &PropagationContext::default())
732 .expect("drag acceleration");
733 let rho = density_for_state(&state, quiet_sw());
734 let v_eci_m_s = state.velocity_km_s * M_PER_KM;
735 let inertial = v_eci_m_s * (-0.5 * rho * BC_FACTOR * v_eci_m_s.norm()) / M_PER_KM;
736
737 assert!(rotating.norm() < inertial.norm());
738 }
739
740 #[test]
741 fn drag_secularly_decreases_energy_and_sma() {
742 const ENERGY_DROP_TOL: f64 = 1.0e-5;
743 const SMA_DROP_TOL_KM: f64 = 1.0e-3;
744 let initial = circular_state(TEST_EPOCH_S, 250.0, 70.0_f64.to_radians());
745 let drag = test_drag_parameters(0.15);
746 let propagator = StatePropagator::new(
747 initial.epoch_tdb_seconds,
748 initial.position_array(),
749 initial.velocity_array(),
750 ForceModelKind::two_body(),
751 IntegratorKind::Dp54,
752 )
753 .with_options(propagation_options())
754 .with_drag(drag);
755 let epochs: Vec<f64> = (0..=12)
756 .map(|i| initial.epoch_tdb_seconds + i as f64 * 600.0)
757 .collect();
758 let states = propagator.ephemeris(&epochs).expect("drag ephemeris");
759 let elapsed: Vec<f64> = states
760 .iter()
761 .map(|state| state.epoch_tdb_seconds - initial.epoch_tdb_seconds)
762 .collect();
763 let energies: Vec<f64> = states.iter().map(specific_energy).collect();
764 let smas: Vec<f64> = states.iter().map(sma_km).collect();
765
766 assert!(energies[energies.len() - 1] < energies[0] - ENERGY_DROP_TOL);
767 assert!(smas[smas.len() - 1] < smas[0] - SMA_DROP_TOL_KM);
768 assert!(slope(&elapsed, &energies) < 0.0);
769 assert!(slope(&elapsed, &smas) < 0.0);
770 }
771
772 #[test]
773 fn no_drag_conserves_energy() {
774 const ENERGY_TOL: f64 = 1.0e-8;
775 const SMA_TOL_KM: f64 = 1.0e-5;
776 let initial = circular_state(TEST_EPOCH_S, 250.0, 70.0_f64.to_radians());
777 let propagator = StatePropagator::new(
778 initial.epoch_tdb_seconds,
779 initial.position_array(),
780 initial.velocity_array(),
781 ForceModelKind::two_body(),
782 IntegratorKind::Dp54,
783 )
784 .with_options(IntegratorOptions {
785 abs_tol: 1.0e-11,
786 rel_tol: 1.0e-13,
787 ..propagation_options()
788 });
789 let epochs: Vec<f64> = (0..=12)
790 .map(|i| initial.epoch_tdb_seconds + i as f64 * 600.0)
791 .collect();
792 let states = propagator.ephemeris(&epochs).expect("no-drag ephemeris");
793 let energies: Vec<f64> = states.iter().map(specific_energy).collect();
794 let smas: Vec<f64> = states.iter().map(sma_km).collect();
795
796 assert!((energies[energies.len() - 1] - energies[0]).abs() <= ENERGY_TOL);
797 assert!((smas[smas.len() - 1] - smas[0]).abs() <= SMA_TOL_KM);
798 }
799
800 #[test]
801 fn near_circular_leo_decay_rate_matches_kinghele_within_tolerance() {
802 const AGREEMENT_TOL: f64 = 0.15;
808 let altitude_km = 360.0;
809 let inclination_rad = 88.0_f64.to_radians();
810 let radius_km = RE_EARTH + altitude_km;
811 let period_s = TAU * (radius_km.powi(3) / MU_EARTH).sqrt();
812 let duration_s = 3.0 * period_s;
813 let initial = circular_state(TEST_EPOCH_S, altitude_km, inclination_rad);
814 let drag = test_drag_parameters(BC_FACTOR);
815
816 let propagator = StatePropagator::new(
817 initial.epoch_tdb_seconds,
818 initial.position_array(),
819 initial.velocity_array(),
820 ForceModelKind::two_body(),
821 IntegratorKind::Dp54,
822 )
823 .with_options(propagation_options())
824 .with_drag(drag);
825 let epochs: Vec<f64> = (0..=18)
826 .map(|i| initial.epoch_tdb_seconds + duration_s * i as f64 / 18.0)
827 .collect();
828 let states = propagator.ephemeris(&epochs).expect("drag ephemeris");
829 let elapsed: Vec<f64> = states
830 .iter()
831 .map(|state| state.epoch_tdb_seconds - initial.epoch_tdb_seconds)
832 .collect();
833 let sma_m: Vec<f64> = states
834 .iter()
835 .map(|state| sma_km(state) * M_PER_KM)
836 .collect();
837 let observed_rate_m_s = slope(&elapsed, &sma_m);
838
839 let samples = 24;
840 let mut density_sum = 0.0;
841 for i in 0..samples {
842 let fraction = i as f64 / samples as f64;
843 let state = circular_state_at_argument(
844 TEST_EPOCH_S + period_s * fraction,
845 altitude_km,
846 inclination_rad,
847 TAU * fraction,
848 );
849 density_sum += density_for_state(&state, quiet_sw());
850 }
851 let mean_density = density_sum / samples as f64;
852 let expected_rate_m_s =
853 -BC_FACTOR * mean_density * (GM_EARTH_M3_S2 * radius_km * M_PER_KM).sqrt();
854
855 let relative_error = ((observed_rate_m_s - expected_rate_m_s) / expected_rate_m_s).abs();
856 assert!(
857 relative_error <= AGREEMENT_TOL,
858 "observed {observed_rate_m_s} m/s expected {expected_rate_m_s} m/s"
859 );
860 }
861
862 #[test]
863 fn drag_zero_above_ceiling_and_below_cutoff() {
864 let at_ceiling = state_from_geodetic_alt(TEST_EPOCH_S, MAX_ALTITUDE_KM);
865 let above_ceiling = state_from_geodetic_alt(TEST_EPOCH_S, MAX_ALTITUDE_KM + 1.0e-3);
866 let accel_ceiling = test_drag(BC_FACTOR)
867 .acceleration(&at_ceiling, &PropagationContext::default())
868 .expect("ceiling evaluates");
869 let accel_above = test_drag(BC_FACTOR)
870 .acceleration(&above_ceiling, &PropagationContext::default())
871 .expect("above ceiling zeroes");
872 assert!(accel_ceiling.norm() > 0.0);
873 assert_eq!(accel_above, Vector3::zeros());
874
875 let cutoff_state = state_from_geodetic_alt(TEST_EPOCH_S, 100.0);
876 let cutoff = geodetic_altitude_km(&cutoff_state).expect("cutoff altitude");
877 let drag =
878 DragForce::from_bc_factor_m2_kg(BC_FACTOR, quiet_sw(), cutoff).expect("valid cutoff");
879 let accel_cutoff = drag
880 .acceleration(&cutoff_state, &PropagationContext::default())
881 .expect("cutoff zeroes");
882 assert_eq!(accel_cutoff, Vector3::zeros());
883
884 let above_cutoff = state_from_geodetic_alt(TEST_EPOCH_S, 100.001);
885 let accel_above_cutoff = drag
886 .acceleration(&above_cutoff, &PropagationContext::default())
887 .expect("above cutoff evaluates");
888 assert!(accel_above_cutoff.norm() > 0.0);
889 }
890
891 #[test]
892 fn constructors_reject_invalid_parameters() {
893 let invalid_sw = SpaceWeather {
894 f107: -1.0,
895 ..SpaceWeather::default()
896 };
897 let cases = [
898 DragParameters::from_area_mass(2.2, 1.0, -1.0, quiet_sw(), 100.0),
899 DragParameters::from_bc_factor_m2_kg(-1.0, quiet_sw(), 100.0),
900 DragParameters::from_bc_factor_m2_kg(BC_FACTOR, invalid_sw, 100.0),
901 DragParameters::from_bc_factor_m2_kg(BC_FACTOR, quiet_sw(), -1.0),
902 ];
903
904 for case in cases {
905 assert!(matches!(case, Err(PropagationError::InvalidInput(_))));
906 }
907 }
908
909 #[test]
910 fn zero_position_is_numerical_failure() {
911 let drag = test_drag(BC_FACTOR);
912 let state = CartesianState::new(TEST_EPOCH_S, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]);
913 let error = drag
914 .acceleration(&state, &PropagationContext::default())
915 .expect_err("zero position fails");
916 assert!(matches!(error, PropagationError::NumericalFailure(_)));
917
918 let mapped = map_frame_error(
919 "geodetic",
920 FrameTransformError::InvalidInput {
921 field: "itrs_position_km",
922 reason: "components must be finite",
923 },
924 );
925 match mapped {
926 PropagationError::NumericalFailure(message) => {
927 assert!(message.contains("drag geodetic"), "{message}");
928 }
929 other => panic!("expected numerical failure, got {other:?}"),
930 }
931 }
932
933 #[test]
934 fn drag_rotation_sign_matches_known_longitude() {
935 const LON_TOL_DEG: f64 = 1.0e-12;
936 let theta = greenwich_mean_sidereal_time_radians_from_j2000_seconds(TEST_EPOCH_S)
937 .expect("valid gmst");
938 let state = CartesianState::new(TEST_EPOCH_S, [RE_EARTH + 400.0, 0.0, 0.0], [0.0; 3]);
939 let geodetic = geodetic_from_validated_state(&state).expect("valid geodetic");
940 let expected_lon = (-theta).to_degrees();
941 let expected_lon = ((expected_lon + 180.0).rem_euclid(360.0)) - 180.0;
942
943 assert!((geodetic.lon_deg - expected_lon).abs() <= LON_TOL_DEG);
944 }
945
946 #[test]
947 fn drag_golden_case_bits() {
948 let state = circular_state(TEST_EPOCH_S, 400.0, 51.6_f64.to_radians());
958 let accel = test_drag(BC_FACTOR)
959 .acceleration(&state, &PropagationContext::default())
960 .expect("drag acceleration");
961
962 assert_eq!(
963 [accel.x.to_bits(), accel.y.to_bits(), accel.z.to_bits()],
964 [
965 9_223_372_036_854_775_808,
966 13_692_397_580_950_677_421,
967 13_694_827_167_186_369_312,
968 ]
969 );
970 }
971}