Skip to main content

sidereon_core/astro/forces/
drag.rs

1//! Atmospheric drag over NRLMSISE-00 density.
2//!
3//! `CartesianState::epoch_tdb_seconds` is interpreted here as continuous seconds
4//! past the J2000 epoch. The drag model treats those seconds directly as the UT
5//! epoch for NRLMSISE-00 calendar inputs and for the GMST-only ECI to ECEF
6//! rotation. For modern satellite drag, the neglected TDB-UT1 and UTC-UT1
7//! offsets are at most about 70 s: about 8e-4 day, 0.02 h of local-solar-time
8//! phase, 2e-6 year of seasonal phase, and 5.1e-3 rad or 0.29 deg of longitude.
9//! These are well below the empirical density uncertainty of NRLMSISE-00.
10//!
11//! Wind is neglected and the atmosphere is treated as co-rotating with Earth,
12//! following the standard Vallado rotating-atmosphere drag convention. Density
13//! caching is intentionally left for a future optimization so each RHS
14//! evaluation is reproducible from the instantaneous state.
15
16use 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/// Space-weather inputs to NRLMSISE-00 for drag.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct SpaceWeather {
40    /// Daily F10.7 from the previous day, solar flux units.
41    pub f107: f64,
42    /// 81-day centered average F10.7, solar flux units.
43    pub f107a: f64,
44    /// Daily magnetic Ap index.
45    pub ap: f64,
46}
47
48impl Default for SpaceWeather {
49    /// Reference quiet-Sun standard inputs from the atmosphere module.
50    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/// Where drag evaluations obtain space-weather values.
60#[derive(Debug, Clone)]
61pub enum SpaceWeatherSource {
62    /// Constant values for every epoch.
63    Fixed(SpaceWeather),
64    /// Per-epoch values from a parsed CelesTrak table.
65    Table(Arc<SpaceWeatherTable>),
66}
67
68impl SpaceWeatherSource {
69    /// Resolve space-weather inputs at one J2000-second epoch.
70    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/// Atmospheric-drag force model using cannonball drag over NRLMSISE-00 density.
79///
80/// The stored factor is `B = C_D * A / m` in m^2/kg. Use
81/// [`CompositeForceModel`](crate::astro::forces::CompositeForceModel) to layer
82/// this force on a gravity model.
83#[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    /// Conventional reentry and decay altitude, km.
92    pub const DEFAULT_REENTRY_ALTITUDE_KM: f64 = 100.0;
93
94    /// Build from drag coefficient, cross-section area, mass, and cutoff.
95    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    /// Build directly from `B = C_D * A / m` in m^2/kg.
107    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    /// Build from reciprocal ballistic coefficient `BC = m / (C_D * A)`.
117    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    /// Build from drag coefficient, area, and mass with the default cutoff.
127    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    /// Drag ballistic-coefficient factor `B = C_D * A / m`, m^2/kg.
138    pub fn bc_factor_m2_kg(&self) -> f64 {
139        self.bc_factor_m2_kg
140    }
141
142    /// Space-weather inputs used for density evaluation.
143    pub fn space_weather(&self) -> SpaceWeather {
144        self.space_weather
145    }
146
147    /// Density cutoff altitude, km.
148    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/// Atmospheric drag whose space weather is resolved per evaluation epoch.
169#[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    /// Build from validated drag parameters and a dynamic source.
178    ///
179    /// The source supplies the per-epoch values; the fixed [`SpaceWeather`] inside
180    /// `drag` is not consulted.
181    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    /// Drag ballistic-coefficient factor `B = C_D * A / m`, m^2/kg.
190    pub fn bc_factor_m2_kg(&self) -> f64 {
191        self.bc_factor_m2_kg
192    }
193
194    /// Space-weather source used for density evaluation.
195    pub fn source(&self) -> &SpaceWeatherSource {
196        &self.source
197    }
198
199    /// Density cutoff altitude, km.
200    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/// Validated drag parameters that can be stored on propagator configs.
224#[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    /// Build from drag coefficient, cross-section area, mass, and cutoff.
233    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    /// Build directly from `B = C_D * A / m` in m^2/kg.
248    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    /// Build from reciprocal ballistic coefficient `BC = m / (C_D * A)`.
264    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    /// Build from drag coefficient, area, and mass with the default cutoff.
274    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    /// Convert to a force model without revalidating.
290    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    /// Drag ballistic-coefficient factor `B = C_D * A / m`, m^2/kg.
299    pub fn bc_factor_m2_kg(&self) -> f64 {
300        self.bc_factor_m2_kg
301    }
302
303    /// Space-weather inputs used for density evaluation.
304    pub fn space_weather(&self) -> SpaceWeather {
305        self.space_weather
306    }
307
308    /// Density cutoff altitude, km.
309    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        // King-Hele near-circular check, using B = 0.02 m^2/kg, altitude 360 km,
799        // inclination 88 deg, quiet-Sun space weather, and a 24-sample
800        // NRLMSISE-00 orbit-mean density. Observed SMA slope:
801        // -5.041449755563205e-3 m/s. Expected averaged rate:
802        // -5.013291857709087e-3 m/s. Relative difference: 5.62e-3.
803        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        // Golden case source: this module's drag path, using NRLMSISE-00 release
945        // 20041227 density through atmosphere::nrlmsise00_with_lst. Inputs:
946        // epoch 646315200.25 s, position [6778.137, 0, 0] km, inclination
947        // 51.6 deg, B 0.02 m^2/kg, F10.7/F10.7a/Ap 150/150/4. Intermediates:
948        // year 2020, doy 177, sec 0.25, GMST 4.775165029523421 rad,
949        // geodetic lat 0 deg, lon 86.4031973298448 deg, alt 400 km,
950        // density 1.9576468755557382e-12 kg/m^3, v_rel [0,
951        // 4.269038333748105, 6.00979886918909] km/s, acceleration [-0,
952        // -6.160751630773568e-10, -8.672884919136098e-10] km/s^2.
953        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}