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