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 = 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        // King-Hele near-circular check, using B = 0.02 m^2/kg, altitude 360 km,
803        // inclination 88 deg, quiet-Sun space weather, and a 24-sample
804        // NRLMSISE-00 orbit-mean density. Observed SMA slope:
805        // -5.041449755563205e-3 m/s. Expected averaged rate:
806        // -5.013291857709087e-3 m/s. Relative difference: 5.62e-3.
807        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        // Golden case source: this module's drag path, using NRLMSISE-00 release
949        // 20041227 density through atmosphere::nrlmsise00_with_lst. Inputs:
950        // epoch 646315200.25 s, position [6778.137, 0, 0] km, inclination
951        // 51.6 deg, B 0.02 m^2/kg, F10.7/F10.7a/Ap 150/150/4. Intermediates:
952        // year 2020, doy 177, sec 0.25, GMST 4.775165029523421 rad,
953        // geodetic lat 0 deg, lon 86.4031973298448 deg, alt 400 km,
954        // density 1.9576468755557382e-12 kg/m^3, v_rel [0,
955        // 4.269038333748105, 6.00979886918909] km/s, acceleration [-0,
956        // -6.160751630773568e-10, -8.672884919136098e-10] km/s^2.
957        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}