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