Skip to main content

nyx_space/dynamics/drag/
mod.rs

1/*
2    Nyx, blazing fast astrodynamics
3    Copyright (C) 2018-onwards Christopher Rabotin <christopher.rabotin@gmail.com>
4
5    This program is free software: you can redistribute it and/or modify
6    it under the terms of the GNU Affero General Public License as published
7    by the Free Software Foundation, either version 3 of the License, or
8    (at your option) any later version.
9
10    This program is distributed in the hope that it will be useful,
11    but WITHOUT ANY WARRANTY; without even the implied warranty of
12    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13    GNU Affero General Public License for more details.
14
15    You should have received a copy of the GNU Affero General Public License
16    along with this program.  If not, see <https://www.gnu.org/licenses/>.
17*/
18
19use super::{
20    DynamicsAlmanacSnafu, DynamicsAstroSnafu, DynamicsError, DynamicsPlanetarySnafu, ForceModel,
21};
22use crate::cosmic::{AstroPhysicsSnafu, Frame, Spacecraft};
23use crate::dynamics::nrlmsise00::Nrlmsise00Flags;
24use crate::dynamics::nrlmsise00::msise00_density;
25use crate::io::space_weather::SpaceWeatherData;
26use crate::linalg::{Matrix4x3, Vector3};
27use crate::time::Unit;
28use anise::constants::frames::{IAU_EARTH_FRAME, SUN_J2000};
29use anise::errors::OrientationSnafu;
30use anise::prelude::Almanac;
31use hifitime::TimeScale;
32use serde::{Deserialize, Serialize};
33use serde_dhall::StaticType;
34use snafu::ResultExt;
35use std::fmt;
36use std::sync::Arc;
37use trig_const::{ln, sqrt};
38
39#[cfg(feature = "python")]
40use pyo3::prelude::*;
41#[cfg(feature = "python")]
42use pyo3::types::PyType;
43
44pub mod nrlmsise00;
45
46const SIN_30_DEG: f64 = 0.5;
47const COS_30_DEG: f64 = sqrt(3.0_f64) / 2.0_f64;
48
49/// Standard Harris-Priester altitude profile node (100 km to 1000 km)
50/// Densities in kg/m^3
51#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
52struct HpNode {
53    alt_km: f64,
54    min_density_kg_m3: f64,
55    max_density_kg_m3: f64,
56}
57
58impl HpNode {
59    const fn ln_min_density(&self) -> f64 {
60        ln(self.min_density_kg_m3)
61    }
62    const fn ln_max_density(&self) -> f64 {
63        ln(self.max_density_kg_m3)
64    }
65}
66
67/// Baseline Harris-Priester reference table for mean solar activity (~F10.7 = 150)
68const HP_TABLE: &[HpNode] = &[
69    HpNode {
70        alt_km: 100.0,
71        min_density_kg_m3: 4.974e-07,
72        max_density_kg_m3: 4.974e-07,
73    },
74    HpNode {
75        alt_km: 120.0,
76        min_density_kg_m3: 2.490e-08,
77        max_density_kg_m3: 2.490e-08,
78    },
79    HpNode {
80        alt_km: 140.0,
81        min_density_kg_m3: 3.840e-09,
82        max_density_kg_m3: 3.840e-09,
83    },
84    HpNode {
85        alt_km: 160.0,
86        min_density_kg_m3: 1.170e-09,
87        max_density_kg_m3: 1.170e-09,
88    },
89    HpNode {
90        alt_km: 180.0,
91        min_density_kg_m3: 4.820e-10,
92        max_density_kg_m3: 5.220e-10,
93    },
94    HpNode {
95        alt_km: 200.0,
96        min_density_kg_m3: 2.260e-10,
97        max_density_kg_m3: 2.620e-10,
98    },
99    HpNode {
100        alt_km: 240.0,
101        min_density_kg_m3: 6.880e-11,
102        max_density_kg_m3: 9.380e-11,
103    },
104    HpNode {
105        alt_km: 280.0,
106        min_density_kg_m3: 2.570e-11,
107        max_density_kg_m3: 4.180e-11,
108    },
109    HpNode {
110        alt_km: 320.0,
111        min_density_kg_m3: 1.090e-11,
112        max_density_kg_m3: 2.060e-11,
113    },
114    HpNode {
115        alt_km: 360.0,
116        min_density_kg_m3: 4.980e-12,
117        max_density_kg_m3: 1.070e-11,
118    },
119    HpNode {
120        alt_km: 400.0,
121        min_density_kg_m3: 2.380e-12,
122        max_density_kg_m3: 5.820e-12,
123    },
124    HpNode {
125        alt_km: 440.0,
126        min_density_kg_m3: 1.180e-12,
127        max_density_kg_m3: 3.250e-12,
128    },
129    HpNode {
130        alt_km: 480.0,
131        min_density_kg_m3: 6.020e-13,
132        max_density_kg_m3: 1.860e-12,
133    },
134    HpNode {
135        alt_km: 520.0,
136        min_density_kg_m3: 3.150e-13,
137        max_density_kg_m3: 1.080e-12,
138    },
139    HpNode {
140        alt_km: 560.0,
141        min_density_kg_m3: 1.680e-13,
142        max_density_kg_m3: 6.400e-13,
143    },
144    HpNode {
145        alt_km: 600.0,
146        min_density_kg_m3: 9.100e-14,
147        max_density_kg_m3: 3.830e-13,
148    },
149    HpNode {
150        alt_km: 680.0,
151        min_density_kg_m3: 2.820e-14,
152        max_density_kg_m3: 1.440e-13,
153    },
154    HpNode {
155        alt_km: 760.0,
156        min_density_kg_m3: 9.200e-15,
157        max_density_kg_m3: 5.760e-14,
158    },
159    HpNode {
160        alt_km: 840.0,
161        min_density_kg_m3: 3.100e-15,
162        max_density_kg_m3: 2.400e-14,
163    },
164    HpNode {
165        alt_km: 920.0,
166        min_density_kg_m3: 1.100e-15,
167        max_density_kg_m3: 1.050e-14,
168    },
169    HpNode {
170        alt_km: 1000.0,
171        min_density_kg_m3: 4.000e-16,
172        max_density_kg_m3: 4.800e-15,
173    },
174];
175
176/// Density in kg/m^3 and altitudes in kilometers
177#[derive(Clone, Debug, Serialize, Deserialize, StaticType)]
178#[cfg_attr(feature = "python", pyclass(from_py_object, get_all, set_all))]
179pub enum AtmDensity {
180    /// Homogeneous, static atmospheric mass density ($\text{kg/m}^3$).
181    ///
182    /// Ignores altitude, spatial position, and temporal variations. Useful for analytical
183    /// baseline tests, sanity-checking drag accelerations, or short propagation steps.
184    Constant(f64),
185
186    /// Barometric scale-height density model.
187    ///
188    /// Evaluates atmospheric density using a single-layer exponential decay:
189    /// $$\rho(h) = \rho_0 \exp\left(-\frac{h - h_0}{H}\right)$$
190    /// where $h$ is geodetic altitude ($\text{m}$), $h_0$ (`ref_alt_m`) is the reference altitude,
191    /// $\rho_0$ (`rho0`) is reference density ($\text{kg/m}^3$), and $H$ (`scale_height_m`) is
192    /// the density scale height.
193    ///
194    /// **Limitations:** Ignores solar/geomagnetic activity and diurnal variations. Accuracy
195    /// degrades rapidly outside a narrow altitude band around $h_0$.
196    Exponential {
197        /// Reference atmospheric density $\rho_0$ at altitude $h_0$ [kg/m³].
198        rho0_kg_m3: f64,
199        /// Reference geodetic altitude $h_0$ [km].
200        ref_alt_km: f64,
201        /// Atmospheric scale height $H = \frac{R T}{M g}$ [km].
202        scale_height_km: f64,
203    },
204
205    /// U.S. Standard Atmosphere 1976 (USSA76) empirical density model.
206    ///
207    /// Evaluates piecewise atmospheric temperature and pressure profiles up to $1,000\text{ km}$ ($10^6\text{ m}$).
208    /// Assumes hydrostatic equilibrium and perfect gas behavior across defined atmospheric layers.
209    ///
210    /// **Limitations:** Static global average model. Does not capture solar EUV heating cycles,
211    /// geomagnetic storm surges, or diurnal day/night atmospheric expansion.
212    StdAtm {
213        /// Maximum operational altitude [km]. Above this threshold, density returns 0.0 kg/m³.
214        max_alt_km: f64,
215    },
216
217    /// NRLMSISE-00 empirical atmosphere model, with optional model flags
218    ///
219    /// Computes neutral atmospheric density and composition from 0 to ~1000 km altitude
220    /// as a function of location, time, solar activity (F10.7), and geomagnetic
221    /// activity (Ap).
222    NRLMSISE00 {
223        weather: SpaceWeatherData,
224        flags: Option<Nrlmsise00Flags>,
225    },
226
227    /// Harris-Priester atmospheric density model.
228    ///
229    /// Computes density accounting for diurnal atmospheric expansion using tabular
230    /// min/max density profiles interpolated exponentially across altitude bands,
231    /// modified by the diurnal bulge angle offset.
232    HarrisPriester {
233        /// Diurnal bulge parameter $n$ (typically 2 for low inclination, 6 for polar).
234        n_parameter: usize,
235    },
236}
237
238#[cfg(feature = "python")]
239#[cfg_attr(feature = "python", pymethods)]
240impl AtmDensity {
241    /// Constructs a standard exponential drag model for Earth orbiters.
242    ///
243    /// Configured with nominal LEO reference parameters at $h_0 = 700\text{ km}$:
244    /// * $\rho_0 = 3.614 \times 10^{-13}\text{ kg/m}^3$
245    /// * $H = 88.667\text{ km}$ ($88,667\text{ m}$)
246    /// :rtype: AtmDensity
247    #[classmethod]
248    fn earth_exponential(_cls: &Bound<'_, PyType>) -> Self {
249        AtmDensity::Exponential {
250            rho0_kg_m3: 3.614e-13,
251            ref_alt_km: 700.000,
252            scale_height_km: 88.667,
253        }
254    }
255}
256
257/// `Drag` implements all three drag models.
258///
259/// :type density: AtmDensity
260/// :type frame: Frame
261/// :type estimate: bool
262#[derive(Clone, Debug, Serialize, Deserialize, StaticType)]
263#[cfg_attr(feature = "python", pyclass(from_py_object, get_all, set_all))]
264pub struct Drag {
265    /// Density computation method
266    pub density: AtmDensity,
267    /// Frame to compute the drag in
268    pub frame: Frame,
269    // Will be added in the next version
270    // Light-time correction computation if the chosen drag model need solar position
271    // pub correction: Option<Aberration>,
272    /// Set to true to estimate the coefficient of drag
273    pub estimate: bool,
274}
275
276impl Drag {
277    /// Constructs a standard exponential drag model for Earth orbiters.
278    ///
279    /// Configured with nominal LEO reference parameters at $h_0 = 700\text{ km}$:
280    /// * $\rho_0 = 3.614 \times 10^{-13}\text{ kg/m}^3$
281    /// * $H = 88.667\text{ km}$ ($88,667\text{ m}$)
282    pub fn earth_exp(almanac: &Almanac) -> Result<Arc<Self>, DynamicsError> {
283        Ok(Arc::new(Self {
284            density: AtmDensity::Exponential {
285                rho0_kg_m3: 3.614e-13,
286                ref_alt_km: 700.000,
287                scale_height_km: 88.667,
288            },
289            frame: almanac
290                .frame_info(IAU_EARTH_FRAME)
291                .context(DynamicsPlanetarySnafu {
292                    action: "planetary data from third body not loaded",
293                })?,
294            estimate: false,
295            // correction: None,
296        }))
297    }
298
299    /// Constructs a U.S. Standard Atmosphere 1976 drag model for Earth orbiters.
300    ///
301    /// Valid for altitudes up to $1,000\text{ km}$ ($1,000,000\text{ m}$). Suitable for general
302    /// trajectory analysis where space weather data ($F_{10.7}$, $A_p$) is unavailable.
303    pub fn std_atm1976(almanac: &Almanac) -> Result<Arc<Self>, DynamicsError> {
304        Ok(Arc::new(Self {
305            density: AtmDensity::StdAtm {
306                max_alt_km: 1_000.0,
307            },
308            frame: almanac
309                .frame_info(IAU_EARTH_FRAME)
310                .context(DynamicsPlanetarySnafu {
311                    action: "planetary data from third body not loaded",
312                })?,
313            estimate: false,
314            // correction: None,
315        }))
316    }
317
318    /// Calculate the density as a private function, since it's duplicated in the EOM and Gradient
319    pub fn rho_kg_m3(&self, ctx: &Spacecraft, almanac: &Almanac) -> Result<f64, DynamicsError> {
320        let osc_drag_frame =
321            almanac
322                .transform_to(ctx.orbit, self.frame, None)
323                .context(DynamicsAlmanacSnafu {
324                    action: "transforming into drag frame",
325                })?;
326
327        let rho_kg_m3 = match &self.density {
328            AtmDensity::Constant(rho) => *rho,
329
330            AtmDensity::Exponential {
331                rho0_kg_m3,
332                scale_height_km,
333                ref_alt_km,
334            } => {
335                let altitude_km = osc_drag_frame
336                    .altitude_km()
337                    .context(AstroPhysicsSnafu)
338                    .context(DynamicsAstroSnafu)?;
339                rho0_kg_m3 * (-(altitude_km - ref_alt_km) / scale_height_km).exp()
340            }
341
342            AtmDensity::StdAtm { max_alt_km } => {
343                let altitude_km = osc_drag_frame
344                    .altitude_km()
345                    .context(AstroPhysicsSnafu)
346                    .context(DynamicsAstroSnafu)?;
347
348                if altitude_km > *max_alt_km {
349                    // Use a constant density
350                    10.0_f64.powf((-7e-5) * altitude_km - 14.464)
351                } else {
352                    // Code from AVS/Schaub's Basilisk
353                    // Calculating the density based on a scaled 6th order polynomial fit to the log of density
354                    let scale = (altitude_km - 526.8000) / 292.8563;
355                    let logdensity =
356                        0.34047 * scale.powi(6) - 0.5889 * scale.powi(5) - 0.5269 * scale.powi(4)
357                            + 1.0036 * scale.powi(3)
358                            + 0.60713 * scale.powi(2)
359                            - 2.3024 * scale
360                            - 12.575;
361
362                    // Calculating density by raising 10 to the log of density
363                    10.0_f64.powf(logdensity)
364                }
365            }
366
367            AtmDensity::NRLMSISE00 { weather, flags } => {
368                let (lat_deg, long_deg, alt_km) = osc_drag_frame
369                    .latlongalt()
370                    .context(AstroPhysicsSnafu)
371                    .context(DynamicsAstroSnafu)?;
372
373                let epoch = ctx.orbit.epoch;
374
375                // If the flags are not set, or if they are set and the mean LST is _disabled_ then use apparent.
376                let lst_h = if let Some(flags) = flags
377                    && !flags.mean_lst
378                {
379                    // Compute the geographic solar time
380                    // TODO Switch to the ANISE impl after https://github.com/nyx-space/anise/issues/42
381                    let sun_state = almanac
382                        .transform(SUN_J2000, self.frame, ctx.orbit.epoch, None)
383                        .context(DynamicsAlmanacSnafu {
384                            action: "computing local solar time",
385                        })?;
386
387                    let sun_long_deg = sun_state.longitude_360_deg();
388                    // Angle between meridians
389                    let delta_lon_deg = long_deg - sun_long_deg;
390                    // Convert to hours (24 hours in 360 degrees), offset by 12 hours for noon definition
391                    // SPICE 12 + (SITLNG - SUNLNG) / 15
392                    (12.0 + (delta_lon_deg / 15.0)).rem_euclid(24.0)
393                } else {
394                    // Use the mean local solar time  in UTC calculation as recommended by the NRLMSISE00 model.
395                    let target_midnight =
396                        epoch.to_time_scale(TimeScale::UTC).with_hms_strict(0, 0, 0);
397                    let hours = (epoch - target_midnight).to_unit(Unit::Hour);
398                    (hours + long_deg / 15.0).rem_euclid(24.0)
399                };
400
401                let sw = weather.msise_weather(epoch);
402
403                msise00_density(
404                    sw,
405                    lst_h,
406                    lat_deg,
407                    long_deg,
408                    alt_km,
409                    epoch,
410                    flags.unwrap_or_default(),
411                )?
412                .total_mass_density_kg_m3
413            }
414
415            AtmDensity::HarrisPriester { n_parameter } => {
416                let altitude_km = osc_drag_frame
417                    .altitude_km()
418                    .context(AstroPhysicsSnafu)
419                    .context(DynamicsAstroSnafu)?;
420
421                if altitude_km < HP_TABLE[0].alt_km
422                    || altitude_km > HP_TABLE[HP_TABLE.len() - 1].alt_km
423                {
424                    0.0
425                } else {
426                    // Find altitude layer
427                    let idx = HP_TABLE
428                        .windows(2)
429                        .position(|w| altitude_km >= w[0].alt_km && altitude_km <= w[1].alt_km)
430                        .unwrap_or(0);
431
432                    let n0 = &HP_TABLE[idx];
433                    let n1 = &HP_TABLE[idx + 1];
434
435                    // Scale height interpolation for min and max density
436                    let h_min =
437                        (n0.alt_km - n1.alt_km) / (n1.ln_min_density() - n0.ln_min_density());
438                    let h_max =
439                        (n0.alt_km - n1.alt_km) / (n1.ln_max_density() - n0.ln_max_density());
440
441                    let rho_min = n0.min_density_kg_m3 * (-(altitude_km - n0.alt_km) / h_min).exp();
442                    let rho_max = n0.max_density_kg_m3 * (-(altitude_km - n0.alt_km) / h_max).exp();
443
444                    // Compute Sun unit vector in drag frame
445                    let u_sun = almanac
446                        .sun_unit_vector(ctx.orbit.epoch, self.frame, None)
447                        .context(DynamicsAlmanacSnafu {
448                            action: "fetching sun position for Harris-Priester model",
449                        })?;
450
451                    // Diurnal bulge apex: lagging Sun by ~30 deg in Right Ascension
452                    let u_bulge = Vector3::new(
453                        u_sun.x * COS_30_DEG - u_sun.y * SIN_30_DEG,
454                        u_sun.x * SIN_30_DEG + u_sun.y * COS_30_DEG,
455                        u_sun.z,
456                    );
457
458                    // Cosine of angle between spacecraft position vector and diurnal bulge apex
459                    let u_pos = osc_drag_frame.r_hat();
460                    let cos_psi = u_pos.dot(&u_bulge).clamp(-1.0, 1.0);
461
462                    // Diurnal variation modifier: cos^(n)(psi / 2)
463                    let cos_half_psi = ((1.0 + cos_psi) / 2.0).sqrt();
464                    let mod_factor = cos_half_psi.powi(*n_parameter as i32);
465
466                    rho_min + (rho_max - rho_min) * mod_factor
467                }
468            }
469        };
470
471        Ok(rho_kg_m3)
472    }
473}
474
475impl fmt::Display for Drag {
476    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
477        write!(
478            f,
479            "\tDrag density {:?} in frame {}",
480            self.density, self.frame
481        )
482    }
483}
484
485impl ForceModel for Drag {
486    fn estimation_index(&self) -> Option<usize> {
487        if self.estimate { Some(7) } else { None }
488    }
489
490    fn eom(&self, ctx: &Spacecraft, almanac: &Almanac) -> Result<Vector3<f64>, DynamicsError> {
491        let integration_frame = ctx.orbit.frame;
492
493        let drag_frame = almanac
494            .frame_info(self.frame)
495            .context(DynamicsPlanetarySnafu {
496                action: "fetching drag frame information",
497            })?;
498
499        let osc_drag_frame =
500            almanac
501                .transform_to(ctx.orbit, self.frame, None)
502                .context(DynamicsAlmanacSnafu {
503                    action: "transforming into drag frame",
504                })?;
505
506        let rho_kg_m3 = self.rho_kg_m3(ctx, almanac)?;
507
508        let v_km_s = osc_drag_frame.velocity_km_s;
509
510        // Note this is in kg*km/s^2 (or kN) because the vehicle mass has not yet been divided.
511        let accel_drag_frame_kg_km_s2 = -0.5
512            * 1e3
513            * rho_kg_m3
514            * ctx.drag.coeff_drag
515            * ctx.drag.area_m2
516            * v_km_s.norm()
517            * v_km_s;
518
519        let accel_integr_frame = almanac
520            .rotate(drag_frame, integration_frame, ctx.orbit.epoch)
521            .context(OrientationSnafu {
522                action: "rotating drafg force into integration frame",
523            })
524            .context(DynamicsAlmanacSnafu {
525                action: "rotating drag force into integration frame",
526            })?
527            * accel_drag_frame_kg_km_s2;
528
529        // Finally, apply the drag model.
530        Ok(accel_integr_frame)
531    }
532
533    /// This model uses central differencing for gradient computation instead of hyperdual numbers.
534    /// This is required given the complexity of the NRLMSISE00 model.
535    fn gradient(
536        &self,
537        ctx: &Spacecraft,
538        almanac: &Almanac,
539    ) -> Result<(Vector3<f64>, Matrix4x3<f64>), DynamicsError> {
540        let dx = self.eom(ctx, almanac)?;
541
542        let mut grad = Matrix4x3::zeros();
543
544        // Central differencing: 6 EOM evaluations, O(h^2) error
545        for j in 0..3 {
546            // Optimal step size for central diff: h ~ eps^(1/3) * |r|
547            let h = 6.0e-6 * ctx.orbit.radius_km[j].abs().max(1.0);
548
549            let mut ctx_plus = *ctx;
550            ctx_plus.orbit.radius_km[j] += h;
551            let f_plus = self.eom(&ctx_plus, almanac)?;
552
553            let mut ctx_minus = *ctx;
554            ctx_minus.orbit.radius_km[j] -= h;
555            let f_minus = self.eom(&ctx_minus, almanac)?;
556
557            let df_dr = (f_plus - f_minus) / (2.0 * h);
558            for i in 0..3 {
559                grad[(i, j)] = df_dr[i];
560            }
561        }
562
563        // Partial wrt Cd: drag acceleration is exactly linear in coeff_drag,
564        // so this is computed analytically rather than by finite differencing.
565        // (This is d(accel)/d(Cd), not d(Cd)/d(Cd) -- the latter is the separate,
566        // legitimately-zero term for Cd's own dynamics under a constant model.)
567        let wrt_cd = dx / ctx.drag.coeff_drag;
568        for j in 0..3 {
569            grad[(3, j)] = wrt_cd[j];
570        }
571
572        Ok((dx, grad))
573    }
574}
575
576#[cfg(feature = "python")]
577#[cfg_attr(feature = "python", pymethods)]
578impl Drag {
579    #[pyo3(signature = (density, frame, estimate=true))]
580    #[new]
581    fn py_new(
582        density: AtmDensity,
583        frame: Frame,
584        estimate: bool,
585        // correction: Option<Aberration>,
586    ) -> Self {
587        Self {
588            density,
589            frame,
590            estimate,
591            // correction,
592        }
593    }
594
595    fn __str__(&self) -> String {
596        format!("{self}")
597    }
598
599    fn __repr__(&self) -> String {
600        format!("{self} @ {self:p}")
601    }
602}