Skip to main content

tpt_sci_astro/
lib.rs

1//! # tpt-sci-astro
2//!
3//! Orbital-mechanics and coordinate-frame primitives for the `tpt-science`
4//! pillar, built entirely from scratch on top of the in-house `tpt-math-linalg`
5//! dense linear algebra (no external astrodynamics or geometry wrappers).
6//!
7//! The crate implements the **classical two-body problem** in an
8//! Earth-Centered Inertial (ECI) reference frame:
9//!
10//! * Classical (Keplerian) [`OrbitalElements`], validated on construction.
11//! * Conversion between Keplerian elements and ECI Cartesian state vectors
12//!   ([`OrbitalElements::state_vector`] and [`OrbitalElements::from_state`]).
13//! * Time propagation via Kepler's equation (`state -> mean anomaly -> advance
14//!   -> solve -> true anomaly`) in [`OrbitalElements::propagate`].
15//! * First-order secular `J₂` perturbation: [`OrbitalElements::propagate_j2`]
16//!   and [`OrbitalElements::j2_secular_rates`] give the dominant long-term
17//!   nodal regression and apsidal precession for oblate-body missions (e.g.
18//!   sun-synchronous orbit design). [`OrbitalElements::propagate_j4`] /
19//!   [`OrbitalElements::j4_secular_rates`] extend this with the `J₄` zonal
20//!   term.
21//! * Atmospheric drag: [`atmospheric_density`] (single-band exponential
22//!   model) and [`OrbitalElements::propagate_drag`] /
23//!   [`OrbitalElements::drag_da_dt`] for secular along-track decay.
24//! * Simplified (Kozai-Lidov, quadrupole-order) third-body secular
25//!   perturbation: [`OrbitalElements::third_body_secular_rates`] /
26//!   [`OrbitalElements::propagate_third_body`].
27//! * Cannonball solar radiation pressure with a cylindrical Earth-shadow
28//!   eclipse test: [`srp_acceleration`], [`in_earth_shadow`],
29//!   [`OrbitalElements::srp_acceleration_vector`].
30//!
31//! All angles are in **radians**. The model assumes an ideal point-mass central
32//! body for the pure two-body propagation; each perturbation above is an
33//! independent first-order secular add-on (not a combined integrated force
34//! model), so short-periodic oscillations are not captured and the
35//! perturbations are not accumulated together automatically.
36//!
37//! # Examples
38//!
39//! ```
40//! use tpt_math_linalg::tpt_math_linalg_dense::{DVector, DMatrix};
41//! use tpt_sci_astro::OrbitalElements;
42//!
43//! // A unit circular orbit about a unit-mass body.
44//! let el = OrbitalElements::new(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0).unwrap();
45//! let (r, _v) = el.state_vector();
46//! assert!((r.norm() - 1.0).abs() < 1e-9);
47//! ```
48//!
49//! Licensed under either of MIT or Apache-2.0 at your option.
50
51use tpt_math_linalg::tpt_math_linalg_dense::{DMatrix, DVector};
52
53pub use error::AstroError;
54
55mod error;
56
57/// Gravitational parameter of the Earth, μ = GM, in km³·s⁻².
58///
59/// Useful as a default when working in Earth-Centered Inertial (ECI) frames
60/// with kilometre and second units.
61pub const EARTH_MU: f64 = 398_600.441_8;
62
63/// Earth's second zonal harmonic `J₂` (dimensionless), the leading oblateness
64/// term that drives nodal regression and apsidal precession.
65pub const EARTH_J2: f64 = 1.082_626_68e-3;
66
67/// Earth's equatorial radius `Rₑ` in km, the reference length for the `J₂`
68/// perturbation (the perturbation scales as `(Rₑ/p)²`).
69pub const EARTH_RADIUS_EQ: f64 = 6378.137;
70
71/// Earth's fourth zonal harmonic `J₄` (dimensionless), the next zonal
72/// oblateness term after `J₂`.
73pub const EARTH_J4: f64 = -1.620_836_15e-6;
74
75/// Reference atmospheric density (kg/m³) for the single-band exponential
76/// Earth atmosphere model, evaluated at [`EARTH_ATM_H0_KM`].
77///
78/// Coefficients are consistent with the ~400 km altitude band of the
79/// exponential atmospheric density model tabulated in Vallado,
80/// *Fundamentals of Astrodynamics and Applications*, 4th ed., Table 8-4
81/// ("Exponential Atmospheric Density Model"), which piecewise-fits Earth's
82/// atmosphere with `ρ(h) = ρ0 · exp(-(h - h0) / H)` in each altitude band.
83/// Adequate for order-of-magnitude drag work in the ~300-500 km LEO band;
84/// not a substitute for a full reference atmosphere (e.g. NRLMSISE-00) for
85/// precision work.
86pub const EARTH_ATM_RHO0_KG_M3: f64 = 5.428e-13;
87
88/// Reference altitude (km) for [`EARTH_ATM_RHO0_KG_M3`] /
89/// [`EARTH_ATM_SCALE_HEIGHT_KM`].
90pub const EARTH_ATM_H0_KM: f64 = 400.0;
91
92/// Atmospheric scale height (km) for the ~400 km exponential density band
93/// (see [`EARTH_ATM_RHO0_KG_M3`]).
94pub const EARTH_ATM_SCALE_HEIGHT_KM: f64 = 58.515;
95
96/// Solar radiation pressure at 1 AU, in N/m² (solar constant / speed of
97/// light, ≈ 1361 W/m² / 2.998e8 m/s).
98pub const SOLAR_PRESSURE_1AU: f64 = 4.56e-6;
99
100/// One astronomical unit, in km.
101pub const ASTRONOMICAL_UNIT_KM: f64 = 1.495_978_707e8;
102
103/// Gravitational parameter of the Sun, `μ☉ = GM☉`, in km³·s⁻².
104pub const SUN_MU: f64 = 1.327_124_400_18e11;
105
106/// Gravitational parameter of the Moon, `μ_Moon = GM_Moon`, in km³·s⁻².
107pub const MOON_MU: f64 = 4_902.800_66;
108
109/// Mean Earth-Moon distance, in km.
110pub const MOON_DISTANCE_KM: f64 = 384_400.0;
111
112/// A classical (Keplerian) set of orbital elements describing an elliptical
113/// orbit in an Earth-Centered Inertial (ECI) frame.
114///
115/// All angles ([`OrbitalElements::i`], [`OrbitalElements::raan`],
116/// [`OrbitalElements::argp`], [`OrbitalElements::nu`]) are stored in **radians**.
117/// The orbit is restricted to the elliptical regime (`0 ≤ e < 1`).
118#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct OrbitalElements {
120    /// Semi-major axis (distance units). Must be strictly positive.
121    pub a: f64,
122    /// Eccentricity. Restricted to `0 ≤ e < 1` (elliptical only).
123    pub e: f64,
124    /// Inclination (radians).
125    pub i: f64,
126    /// Right ascension of the ascending node (radians).
127    pub raan: f64,
128    /// Argument of periapsis (radians).
129    pub argp: f64,
130    /// True anomaly (radians).
131    pub nu: f64,
132    /// Gravitational parameter μ of the central body (distance³·time⁻²).
133    pub mu: f64,
134}
135
136impl OrbitalElements {
137    /// Construct and validate a set of orbital elements.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`AstroError::InvalidElements`] when any invariant is violated:
142    /// `a` must be positive, `e` must satisfy `0 ≤ e < 1` (elliptical only),
143    /// `mu` must be positive, and all angles must be finite.
144    pub fn new(
145        a: f64,
146        e: f64,
147        i: f64,
148        raan: f64,
149        argp: f64,
150        nu: f64,
151        mu: f64,
152    ) -> Result<Self, AstroError> {
153        if !a.is_finite() || a <= 0.0 {
154            return Err(AstroError::InvalidElements(format!(
155                "semi-major axis must be > 0, got {a}"
156            )));
157        }
158        if !e.is_finite() || !(0.0..1.0).contains(&e) {
159            return Err(AstroError::InvalidElements(format!(
160                "eccentricity must satisfy 0 <= e < 1, got {e}"
161            )));
162        }
163        for (name, x) in [
164            ("inclination", i),
165            ("raan", raan),
166            ("argument of periapsis", argp),
167            ("true anomaly", nu),
168        ] {
169            if !x.is_finite() {
170                return Err(AstroError::InvalidElements(format!(
171                    "{name} must be finite"
172                )));
173            }
174        }
175        if !mu.is_finite() || mu <= 0.0 {
176            return Err(AstroError::InvalidElements(format!(
177                "gravitational parameter must be > 0, got {mu}"
178            )));
179        }
180        Ok(Self {
181            a,
182            e,
183            i,
184            raan,
185            argp,
186            nu,
187            mu,
188        })
189    }
190
191    /// Orbital period `T = 2π √(a³ / μ)` (seconds for SI-like units).
192    #[must_use]
193    pub fn period(&self) -> f64 {
194        const TWO_PI: f64 = 2.0 * std::f64::consts::PI;
195        TWO_PI * (self.a.powi(3) / self.mu).sqrt()
196    }
197
198    /// Compute the ECI position and velocity vectors from these elements.
199    ///
200    /// Returns `(r_eci, v_eci)`, each a length-3 [`DVector`]. The position is
201    /// obtained in the perifocal frame and rotated into ECI; the velocity
202    /// follows the same rotation.
203    #[must_use]
204    pub fn state_vector(&self) -> (DVector<f64>, DVector<f64>) {
205        let p = self.a * (1.0 - self.e.powi(2));
206        let r = p / (1.0 + self.e * self.nu.cos());
207        let r_pf = DVector::from_vec(vec![r * self.nu.cos(), r * self.nu.sin(), 0.0]);
208
209        let v_scale = (self.mu / p).sqrt();
210        let v_pf = DVector::from_vec(vec![-self.nu.sin(), self.e + self.nu.cos(), 0.0]) * v_scale;
211
212        let q = perifocal_to_eci(self.raan, self.i, self.argp);
213        let r_eci = q.clone() * r_pf;
214        let v_eci = q * v_pf;
215        (r_eci, v_eci)
216    }
217
218    /// Recover classical orbital elements from an ECI position/velocity state.
219    ///
220    /// Implements the standard inverse two-body transform. Edge cases (nearly
221    /// circular orbits and/or equatorial orbits) are handled by clamping the
222    /// relevant angle to a conventional zero so the transformation stays
223    /// well-defined.
224    ///
225    /// # Errors
226    ///
227    /// Returns [`AstroError::InvalidElements`] if the inputs are non-finite or
228    /// `mu` is not positive, or [`AstroError::DegenerateGeometry`] if the state
229    /// is degenerate (zero position magnitude, zero angular momentum, or a
230    /// non-elliptical `a`).
231    pub fn from_state(pos: &DVector<f64>, vel: &DVector<f64>, mu: f64) -> Result<Self, AstroError> {
232        if !mu.is_finite() || mu <= 0.0 {
233            return Err(AstroError::InvalidElements(format!(
234                "gravitational parameter must be > 0, got {mu}"
235            )));
236        }
237        for (name, v) in [("position", pos), ("velocity", vel)] {
238            for (k, x) in v.iter().enumerate() {
239                if !x.is_finite() {
240                    return Err(AstroError::InvalidElements(format!(
241                        "{name} component {k} must be finite"
242                    )));
243                }
244            }
245        }
246
247        let r = pos.norm();
248        let v = vel.norm();
249        if r <= 0.0 {
250            return Err(AstroError::DegenerateGeometry(
251                "position magnitude is zero".to_string(),
252            ));
253        }
254
255        let energy = v * v / 2.0 - mu / r;
256        let a = -mu / (2.0 * energy);
257        if !a.is_finite() || a <= 0.0 {
258            return Err(AstroError::DegenerateGeometry(
259                "semi-major axis is not positive (orbit is not elliptical)".to_string(),
260            ));
261        }
262
263        let h = cross3(pos, vel);
264        let h_mag = h.norm();
265        if h_mag <= 0.0 {
266            return Err(AstroError::DegenerateGeometry(
267                "angular momentum is zero".to_string(),
268            ));
269        }
270
271        let e_vec = (pos.clone() * (v * v - mu / r) - vel.clone() * pos.dot(vel)) / mu;
272        let e = e_vec.norm();
273
274        let i = (h[2] / h_mag).clamp(-1.0, 1.0).acos();
275
276        // Node vector n = k × h, with k = [0,0,1].
277        let n = DVector::from_vec(vec![-h[1], h[0], 0.0]);
278        let n_mag = n.norm();
279
280        let raan = if n_mag < 1e-12 {
281            // Equatorial orbit: RAAN is undefined; choose 0.
282            0.0
283        } else {
284            let mut raan = (n[0] / n_mag).clamp(-1.0, 1.0).acos();
285            if n[1] < 0.0 {
286                raan = 2.0 * std::f64::consts::PI - raan;
287            }
288            raan
289        };
290
291        let argp = if e < 1e-12 || n_mag < 1e-12 {
292            // Circular orbit: argument of periapsis is undefined; choose 0.
293            0.0
294        } else {
295            let mut argp = (n.dot(&e_vec) / (n_mag * e)).clamp(-1.0, 1.0).acos();
296            if e_vec[2] < 0.0 {
297                argp = 2.0 * std::f64::consts::PI - argp;
298            }
299            argp
300        };
301
302        let nu = if e < 1e-12 {
303            // Circular orbit: true anomaly measured from the node line.
304            if n_mag < 1e-12 {
305                0.0
306            } else {
307                let mut nu = (n.dot(pos) / (n_mag * r)).clamp(-1.0, 1.0).acos();
308                if pos.dot(vel) < 0.0 {
309                    nu = 2.0 * std::f64::consts::PI - nu;
310                }
311                nu
312            }
313        } else {
314            let mut nu = (e_vec.dot(pos) / (e * r)).clamp(-1.0, 1.0).acos();
315            if pos.dot(vel) < 0.0 {
316                nu = 2.0 * std::f64::consts::PI - nu;
317            }
318            nu
319        };
320
321        OrbitalElements::new(a, e, i, raan, argp, nu, mu)
322    }
323
324    /// Advance the orbit by `dt` seconds and return the new elements.
325    ///
326    /// Propagation is performed by converting the current true anomaly to a
327    /// mean anomaly, applying the mean motion over `dt`, solving Kepler's
328    /// equation for the new eccentric anomaly (Newton iteration), and converting
329    /// back to a true anomaly. The other elements (`a`, `e`, `i`, `raan`,
330    /// `argp`, `mu`) are unchanged.
331    #[must_use]
332    pub fn propagate(&self, dt: f64) -> OrbitalElements {
333        let n_motion = (self.mu / self.a.powi(3)).sqrt();
334        let e = self.e;
335
336        let e0 = true_to_eccentric(self.nu, e);
337        let m0 = e0 - e * e0.sin();
338        let m1 = m0 + n_motion * dt;
339        let e1 = solve_kepler(m1, e);
340        let nu1 = eccentric_to_true(e1, e);
341
342        let mut nu = nu1;
343        // Keep nu in a canonical [0, 2π) range for stable comparisons.
344        nu = nu.rem_euclid(2.0 * std::f64::consts::PI);
345
346        OrbitalElements {
347            a: self.a,
348            e: self.e,
349            i: self.i,
350            raan: self.raan,
351            argp: self.argp,
352            nu,
353            mu: self.mu,
354        }
355    }
356
357    /// First-order secular J2 perturbation rates (Brouwer/Lyddane secular
358    /// terms) for this orbit: the time derivatives of the right ascension of
359    /// the ascending node (`Ω̇`) and the argument of periapsis (`ω̇`).
360    ///
361    /// These are the dominant long-term perturbations from Earth's oblateness:
362    /// `Ω̇` (nodal regression) and `ω̇` (apsidal precession). They depend on the
363    /// `J₂` coefficient and the reference equatorial radius `r_eq` (both
364    /// supplied so the same routine works for any oblate body). The semi-major
365    /// axis, eccentricity, and inclination are constant to first order in `J₂`.
366    ///
367    /// Returns `(raan_dot, argp_dot)` in radians per unit time.
368    #[must_use]
369    pub fn j2_secular_rates(&self, j2: f64, r_eq: f64) -> (f64, f64) {
370        let n = (self.mu / self.a.powi(3)).sqrt();
371        let p = self.a * (1.0 - self.e.powi(2));
372        let factor = 1.5 * n * j2 * (r_eq / p).powi(2);
373        let ci = self.i.cos();
374        let raan_dot = -factor * ci;
375        let argp_dot = 0.5 * factor * (5.0 * ci * ci - 1.0);
376        (raan_dot, argp_dot)
377    }
378
379    /// Propagate the orbit under the first-order secular `J₂` perturbation for
380    /// `dt` units of time.
381    ///
382    /// The in-plane motion (mean anomaly / true anomaly) is advanced with the
383    /// two-body mean motion, while the secular `J₂` drifts are added to the
384    /// right ascension of the ascending node and the argument of periapsis. The
385    /// semi-major axis, eccentricity, and inclination are held fixed (they are
386    /// constant to first order in `J₂`). This is the standard model used for
387    /// long-term RAAN drift and sun-synchronous orbit design; it does not
388    /// include short-periodic `J₂` oscillations.
389    ///
390    /// `j2` is the body's zonal harmonic (e.g. [`EARTH_J2`]) and `r_eq` its
391    /// equatorial radius (e.g. [`EARTH_RADIUS_EQ`]).
392    #[must_use]
393    pub fn propagate_j2(&self, dt: f64, j2: f64, r_eq: f64) -> OrbitalElements {
394        let in_plane = self.propagate(dt);
395        let (raan_dot, argp_dot) = self.j2_secular_rates(j2, r_eq);
396        let two_pi = 2.0 * std::f64::consts::PI;
397        OrbitalElements {
398            a: in_plane.a,
399            e: in_plane.e,
400            i: in_plane.i,
401            raan: (in_plane.raan + raan_dot * dt).rem_euclid(two_pi),
402            argp: (in_plane.argp + argp_dot * dt).rem_euclid(two_pi),
403            nu: in_plane.nu,
404            mu: in_plane.mu,
405        }
406    }
407
408    /// Secular along-track semi-major-axis decay rate due to atmospheric
409    /// drag (distance units of `a` per unit time, e.g. km/s).
410    ///
411    /// Uses the standard first-order averaged drag decay rate (Vallado,
412    /// *Fundamentals of Astrodynamics and Applications*, 4th ed., §8.6.3
413    /// "Atmospheric Drag"; also King-Hele, *Theory of Satellite Orbits in an
414    /// Atmosphere*, 1964):
415    ///
416    /// `da/dt = -(ρ · Cd·A/m) · n · a² · √((1+e)/(1-e))`
417    ///
418    /// `ρ` is the atmospheric density ([`atmospheric_density`]) evaluated at
419    /// the current perigee altitude (drag is dominated by the perigee pass
420    /// for eccentric orbits; for a circular orbit perigee altitude reduces
421    /// to `a - Rₑ`), `n` is the mean motion, and `cd_a_over_m` is the
422    /// ballistic drag term `Cd·A/m` in m²/kg. The `a²` factor is evaluated
423    /// in metres so it is dimensionally consistent with `ρ` (kg/m³) and
424    /// `cd_a_over_m` (m²/kg); the result is converted back to the same
425    /// distance unit as `a` (assumed km, matching [`EARTH_RADIUS_EQ`]).
426    #[must_use]
427    pub fn drag_da_dt(&self, cd_a_over_m: f64) -> f64 {
428        let perigee_altitude_km = self.a * (1.0 - self.e) - EARTH_RADIUS_EQ;
429        let rho = atmospheric_density(perigee_altitude_km);
430        let n = (self.mu / self.a.powi(3)).sqrt();
431        let a_m = self.a * 1000.0;
432        let ecc_factor = ((1.0 + self.e) / (1.0 - self.e)).sqrt();
433        let da_dt_m_per_s = -rho * cd_a_over_m * n * a_m * a_m * ecc_factor;
434        da_dt_m_per_s / 1000.0
435    }
436
437    /// Propagate the orbit including secular along-track decay of the
438    /// semi-major axis due to atmospheric drag, over `dt` seconds.
439    ///
440    /// Combines ordinary two-body propagation ([`OrbitalElements::propagate`])
441    /// with a linear-in-`dt` decay of `a` from
442    /// [`OrbitalElements::drag_da_dt`]. Eccentricity, inclination, and the
443    /// node/apsis angles are held fixed; a full drag model also
444    /// circularizes the orbit by damping `e`, but this first-order model
445    /// captures only the dominant `a` decay (matching the level of the
446    /// existing `J₂` model). `cd_a_over_m` is the ballistic term `Cd·A/m` in
447    /// m²/kg.
448    ///
449    /// # Examples
450    ///
451    /// ```
452    /// use tpt_sci_astro::{OrbitalElements, EARTH_MU};
453    /// let el = OrbitalElements::new(6778.0, 0.001, 0.9, 0.0, 0.0, 0.0, EARTH_MU).unwrap();
454    /// let decayed = el.propagate_drag(3600.0, 0.02);
455    /// assert!(decayed.a < el.a); // drag always shrinks the semi-major axis
456    /// ```
457    #[must_use]
458    pub fn propagate_drag(&self, dt: f64, cd_a_over_m: f64) -> OrbitalElements {
459        let in_plane = self.propagate(dt);
460        let da_dt = self.drag_da_dt(cd_a_over_m);
461        let new_a = (in_plane.a + da_dt * dt).max(EARTH_RADIUS_EQ * 1e-3);
462        OrbitalElements {
463            a: new_a,
464            ..in_plane
465        }
466    }
467
468    /// First-order secular perturbation rates due to a third body (e.g. the
469    /// Sun or Moon) restricted to a circular orbit lying in the reference
470    /// plane, to quadrupole order in `a / d` (the classic Kozai (1959) /
471    /// Lidov (1961) restricted third-body secular theory; see also Murray &
472    /// Dermott, *Solar System Dynamics*, §7, and Naoz, "The Eccentric
473    /// Kozai-Lidov Effect", *ARA&A* 54 (2016), the quadrupole term with the
474    /// perturber's own eccentricity set to zero).
475    ///
476    /// `mu_third` is the perturbing body's gravitational parameter (e.g.
477    /// [`SUN_MU`] or [`MOON_MU`]) and `dist_third` its distance from the
478    /// central body (e.g. [`ASTRONOMICAL_UNIT_KM`] or [`MOON_DISTANCE_KM`]).
479    ///
480    /// Returns `(raan_dot, argp_dot, i_dot, e_dot)`. Unlike the pure `J₂`
481    /// model, a third body secularly couples eccentricity and inclination
482    /// (the Kozai-Lidov mechanism): to first order the combination
483    /// `(1-e²)cos²i` is conserved by `(i_dot, e_dot)` alone.
484    ///
485    /// This is a simplification: the real Sun and Moon orbits are neither
486    /// circular nor coplanar with the Earth's equator, so this captures the
487    /// dominant secular behaviour rather than exact lunisolar perturbations.
488    #[must_use]
489    pub fn third_body_secular_rates(&self, mu_third: f64, dist_third: f64) -> (f64, f64, f64, f64) {
490        let n = (self.mu / self.a.powi(3)).sqrt();
491        let n3_sq = mu_third / dist_third.powi(3);
492        let beta = n3_sq / n;
493
494        let e = self.e;
495        let e2 = e * e;
496        let ome2 = (1.0 - e2).max(1e-12);
497        let sqrt_ome2 = ome2.sqrt();
498
499        let ci = self.i.cos();
500        let si = self.i.sin();
501        let ci2 = ci * ci;
502        let si2 = si * si;
503
504        let s2w = (2.0 * self.argp).sin();
505        let c2w = (2.0 * self.argp).cos();
506
507        let e_dot = (15.0 / 8.0) * beta * sqrt_ome2 * e * si2 * s2w;
508        let i_dot = -(15.0 / 16.0) * beta * e2 * s2w * (2.0 * si * ci) / sqrt_ome2;
509        let raan_dot = (3.0 / 8.0) * beta * (ci / sqrt_ome2) * (2.0 + 3.0 * e2 - 5.0 * e2 * c2w);
510        let argp_dot_e_term =
511            (3.0 / 8.0) * beta * sqrt_ome2 * ((3.0 * ci2 - 1.0) + 5.0 * si2 * c2w);
512        let argp_dot_i_term =
513            (3.0 / 8.0) * beta * (ci2 / sqrt_ome2) * (2.0 + 3.0 * e2 - 5.0 * e2 * c2w);
514        let argp_dot = argp_dot_e_term + argp_dot_i_term;
515
516        (raan_dot, argp_dot, i_dot, e_dot)
517    }
518
519    /// Propagate the orbit under the simplified secular third-body
520    /// perturbation ([`OrbitalElements::third_body_secular_rates`]) for `dt`
521    /// seconds.
522    ///
523    /// # Errors
524    ///
525    /// Returns [`AstroError::InvalidElements`] if the perturbed elements (in
526    /// particular `e`, which a third body secularly pumps via the
527    /// Kozai-Lidov mechanism) leave the valid range; callers integrating
528    /// over long spans should use small steps.
529    pub fn propagate_third_body(
530        &self,
531        dt: f64,
532        mu_third: f64,
533        dist_third: f64,
534    ) -> Result<OrbitalElements, AstroError> {
535        let in_plane = self.propagate(dt);
536        let (raan_dot, argp_dot, i_dot, e_dot) =
537            self.third_body_secular_rates(mu_third, dist_third);
538        let two_pi = 2.0 * std::f64::consts::PI;
539        OrbitalElements::new(
540            in_plane.a,
541            (in_plane.e + e_dot * dt).clamp(0.0, 1.0 - 1e-9),
542            (in_plane.i + i_dot * dt).clamp(0.0, std::f64::consts::PI),
543            (in_plane.raan + raan_dot * dt).rem_euclid(two_pi),
544            (in_plane.argp + argp_dot * dt).rem_euclid(two_pi),
545            in_plane.nu,
546            in_plane.mu,
547        )
548    }
549
550    /// Cannonball-model solar radiation pressure acceleration vector in ECI,
551    /// in km/s², including the cylindrical Earth-shadow eclipse test
552    /// ([`in_earth_shadow`]).
553    ///
554    /// `cr` is the dimensionless radiation-pressure coefficient (`1` for a
555    /// perfectly absorbing surface, up to `2` for perfectly reflecting),
556    /// `area_to_mass_m2_per_kg` is `A/m` in m²/kg, and `sun_pos_km` is the
557    /// Sun's ECI position relative to the central body (km). Returns the
558    /// zero vector when the satellite is in Earth's shadow.
559    #[must_use]
560    pub fn srp_acceleration_vector(
561        &self,
562        cr: f64,
563        area_to_mass_m2_per_kg: f64,
564        sun_pos_km: &DVector<f64>,
565    ) -> DVector<f64> {
566        let (r_eci, _v) = self.state_vector();
567        if in_earth_shadow(&r_eci, sun_pos_km, EARTH_RADIUS_EQ) {
568            return DVector::from_vec(vec![0.0, 0.0, 0.0]);
569        }
570        let sun_dist = sun_pos_km.norm();
571        if sun_dist <= 0.0 {
572            return DVector::from_vec(vec![0.0, 0.0, 0.0]);
573        }
574        // Force direction is Sun -> satellite; since |r_eci| << sun_dist this
575        // is well-approximated by the anti-sunward direction from Earth.
576        let dir = sun_pos_km.clone() * (-1.0 / sun_dist);
577        let mag = srp_acceleration(cr, area_to_mass_m2_per_kg, sun_dist);
578        dir * mag
579    }
580
581    /// Combined first-order secular `J₂` + `J₄` zonal-harmonic perturbation
582    /// rates (nodal regression `Ω̇` and apsidal precession `ω̇`).
583    ///
584    /// Extends [`OrbitalElements::j2_secular_rates`] with the next zonal
585    /// term. The `J₄` contribution follows the standard combined-zonal
586    /// secular theory (e.g. Vallado, *Fundamentals of Astrodynamics and
587    /// Applications*, §9.2 "Combined Effects of Zonal Harmonics"; Schaub &
588    /// Junkins, *Analytical Mechanics of Space Systems*, ch. 9): it is a
589    /// small correction, of relative order `(J₄/J₂)·(Rₑ/p)²` versus the `J₂`
590    /// term, that partially offsets the `J₂` nodal regression for typical
591    /// low/mid inclinations.
592    ///
593    /// Returns `(raan_dot, argp_dot)` in radians per unit time, `J₂` and
594    /// `J₄` combined.
595    #[must_use]
596    pub fn j4_secular_rates(&self, j2: f64, j4: f64, r_eq: f64) -> (f64, f64) {
597        let (raan_dot_j2, argp_dot_j2) = self.j2_secular_rates(j2, r_eq);
598
599        let n = (self.mu / self.a.powi(3)).sqrt();
600        let p = self.a * (1.0 - self.e.powi(2));
601        let factor4 = n * j4 * (r_eq / p).powi(4);
602        let ci = self.i.cos();
603        let si2 = self.i.sin().powi(2);
604
605        let raan_dot_j4 = (15.0 / 32.0) * factor4 * ci * (12.0 - 21.0 * si2);
606        let argp_dot_j4 = -(45.0 / 128.0) * factor4 * (8.0 - 40.0 * si2 + 35.0 * si2 * si2);
607
608        (raan_dot_j2 + raan_dot_j4, argp_dot_j2 + argp_dot_j4)
609    }
610
611    /// Propagate the orbit under the combined secular `J₂` + `J₄`
612    /// perturbation for `dt` units of time.
613    ///
614    /// See [`OrbitalElements::j4_secular_rates`] and
615    /// [`OrbitalElements::propagate_j2`] (the `J₂`-only analogue, whose
616    /// in-plane / secular-drift structure this mirrors exactly).
617    #[must_use]
618    pub fn propagate_j4(&self, dt: f64, j2: f64, j4: f64, r_eq: f64) -> OrbitalElements {
619        let in_plane = self.propagate(dt);
620        let (raan_dot, argp_dot) = self.j4_secular_rates(j2, j4, r_eq);
621        let two_pi = 2.0 * std::f64::consts::PI;
622        OrbitalElements {
623            a: in_plane.a,
624            e: in_plane.e,
625            i: in_plane.i,
626            raan: (in_plane.raan + raan_dot * dt).rem_euclid(two_pi),
627            argp: (in_plane.argp + argp_dot * dt).rem_euclid(two_pi),
628            nu: in_plane.nu,
629            mu: in_plane.mu,
630        }
631    }
632}
633
634/// Exponential atmospheric density model, `ρ(h) = ρ0 · exp(-(h - h0) / H)`.
635///
636/// A single-band exponential fit to Earth's atmosphere (see
637/// [`EARTH_ATM_RHO0_KG_M3`] for the reference and its limitations), adequate
638/// for order-of-magnitude drag estimates in the ~300-500 km LEO band.
639/// `altitude_km` is the geodetic altitude above the Earth's surface in km.
640/// Returns density in kg/m³.
641///
642/// # Examples
643///
644/// ```
645/// use tpt_sci_astro::{atmospheric_density, EARTH_ATM_H0_KM, EARTH_ATM_RHO0_KG_M3};
646/// let rho = atmospheric_density(EARTH_ATM_H0_KM);
647/// assert!((rho - EARTH_ATM_RHO0_KG_M3).abs() < 1e-20);
648/// assert!(atmospheric_density(500.0) < rho); // density falls off with altitude
649/// ```
650#[must_use]
651pub fn atmospheric_density(altitude_km: f64) -> f64 {
652    EARTH_ATM_RHO0_KG_M3 * (-(altitude_km - EARTH_ATM_H0_KM) / EARTH_ATM_SCALE_HEIGHT_KM).exp()
653}
654
655/// Cylindrical Earth-shadow (eclipse) test.
656///
657/// Returns `true` when `pos` (satellite position relative to the central
658/// body, km) lies within the infinite cylinder of radius `earth_radius`
659/// extending anti-sunward from the Earth, i.e. the satellite is
660/// geometrically eclipsed under the standard cylindrical-shadow
661/// approximation (ignoring the Sun's finite angular size / penumbra, which
662/// is adequate for cannonball SRP on/off modeling).
663///
664/// `sun_pos` is the Sun's position relative to the central body (same frame
665/// and units as `pos`, e.g. ECI km).
666#[must_use]
667pub fn in_earth_shadow(pos: &DVector<f64>, sun_pos: &DVector<f64>, earth_radius: f64) -> bool {
668    let sun_dist = sun_pos.norm();
669    if sun_dist <= 0.0 {
670        return false;
671    }
672    let sun_dir = sun_pos.clone() * (1.0 / sun_dist);
673    let along = pos.dot(&sun_dir);
674    if along >= 0.0 {
675        // Satellite is on the sunward side (or in the terminator plane): lit.
676        return false;
677    }
678    let perp = pos.clone() - sun_dir * along;
679    perp.norm() < earth_radius
680}
681
682/// Cannonball-model solar radiation pressure acceleration magnitude, in
683/// km/s².
684///
685/// `cr` is the dimensionless radiation-pressure coefficient (`1` for a
686/// perfectly absorbing surface, up to `2` for perfectly reflecting),
687/// `area_to_mass_m2_per_kg` is `A/m` in m²/kg, and `sun_distance_km` is the
688/// satellite-to-Sun distance. `P_srp` at 1 AU is [`SOLAR_PRESSURE_1AU`],
689/// scaled by the inverse-square law to the actual Sun distance:
690/// `F = P_srp · Cr · A/m`.
691#[must_use]
692pub fn srp_acceleration(cr: f64, area_to_mass_m2_per_kg: f64, sun_distance_km: f64) -> f64 {
693    let p_srp = SOLAR_PRESSURE_1AU * (ASTRONOMICAL_UNIT_KM / sun_distance_km).powi(2);
694    let accel_m_s2 = p_srp * cr * area_to_mass_m2_per_kg;
695    accel_m_s2 / 1000.0
696}
697
698/// The 3×3 rotation matrix `Q` that maps perifocal-frame coordinates to ECI.
699///
700/// `Q` is built from the right ascension of the ascending node `raan`, the
701/// inclination `i`, and the argument of periapsis `argp`.
702#[must_use]
703pub fn perifocal_to_eci(raan: f64, i: f64, argp: f64) -> DMatrix<f64> {
704    let c_o = raan.cos();
705    let s_o = raan.sin();
706    let ci = i.cos();
707    let si = i.sin();
708    let cw = argp.cos();
709    let sw = argp.sin();
710
711    DMatrix::from_fn(3, 3, |row, col| match (row, col) {
712        (0, 0) => c_o * cw - s_o * sw * ci,
713        (0, 1) => -c_o * sw - s_o * cw * ci,
714        (0, 2) => s_o * si,
715        (1, 0) => s_o * cw + c_o * sw * ci,
716        (1, 1) => -s_o * sw + c_o * cw * ci,
717        (1, 2) => -c_o * si,
718        (2, 0) => sw * si,
719        (2, 1) => cw * si,
720        (2, 2) => ci,
721        _ => 0.0,
722    })
723}
724
725/// Cross product of two 3-vectors, `a × b`.
726///
727/// The inputs are treated as 3-vectors; only the first three components are
728/// used. Returns a length-3 [`DVector`].
729#[must_use]
730pub fn cross3(a: &DVector<f64>, b: &DVector<f64>) -> DVector<f64> {
731    DVector::from_vec(vec![
732        a[1] * b[2] - a[2] * b[1],
733        a[2] * b[0] - a[0] * b[2],
734        a[0] * b[1] - a[1] * b[0],
735    ])
736}
737
738/// Convert a true anomaly `nu` to an eccentric anomaly `E` for eccentricity `e`.
739#[must_use]
740pub fn true_to_eccentric(nu: f64, e: f64) -> f64 {
741    let s = (1.0 - e).sqrt() * (nu / 2.0).sin();
742    let c = (1.0 + e).sqrt() * (nu / 2.0).cos();
743    2.0 * s.atan2(c)
744}
745
746/// Convert an eccentric anomaly `E` to a true anomaly `nu` for eccentricity `e`.
747#[must_use]
748pub fn eccentric_to_true(ecc: f64, e: f64) -> f64 {
749    let s = (1.0 + e).sqrt() * (ecc / 2.0).sin();
750    let c = (1.0 - e).sqrt() * (ecc / 2.0).cos();
751    2.0 * s.atan2(c)
752}
753
754/// Solve Kepler's equation `M = E - e·sin(E)` for `E` via Newton iteration.
755///
756/// Converges quadratically for `0 ≤ e < 1`. The iteration is seeded with
757/// `E₀ = M + e·sin(M)`, a first-order series approximation of the solution
758/// (Danby 1992, ch. 6). Unlike a bare `E₀ = M` seed this stays close to the
759/// root for near-parabolic orbits (`e → 1`), where `E ≈ M + e` and a `M` seed
760/// degrades badly. A finite-difference step guards the derivative singularity
761/// as `e → 1` so the loop still terminates when `1 - e·cos(E)` approaches zero.
762#[must_use]
763pub fn solve_kepler(m: f64, e: f64) -> f64 {
764    debug_assert!((0.0..1.0).contains(&e), "solve_kepler requires 0 ≤ e < 1");
765    let mut ecc = m + e * m.sin();
766    for _ in 0..60 {
767        let f = ecc - e * ecc.sin() - m;
768        let fp = 1.0 - e * ecc.cos();
769        let delta = f / fp;
770        ecc -= delta;
771        if delta.abs() < 1e-12 {
772            break;
773        }
774    }
775    ecc
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781    use approx::assert_abs_diff_eq;
782
783    fn vec3(x: f64, y: f64, z: f64) -> DVector<f64> {
784        DVector::from_vec(vec![x, y, z])
785    }
786
787    #[test]
788    fn circular_orbit_state_norms() {
789        // e = 0, a = 1, mu = 1 -> r = 1, v = 1 for every true anomaly.
790        let el = OrbitalElements::new(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0).unwrap();
791        for nu in [0.0, 0.3, 1.0, 2.5, 5.0] {
792            let e = OrbitalElements::new(1.0, 0.0, 0.0, 0.0, 0.0, nu, 1.0).unwrap();
793            let (r, v) = e.state_vector();
794            assert_abs_diff_eq!(r.norm(), 1.0, epsilon = 1e-9);
795            assert_abs_diff_eq!(v.norm(), 1.0, epsilon = 1e-9);
796        }
797        let _ = el;
798    }
799
800    #[test]
801    fn period_unit_circle() {
802        let el = OrbitalElements::new(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0).unwrap();
803        assert_abs_diff_eq!(el.period(), 2.0 * std::f64::consts::PI, epsilon = 1e-9);
804    }
805
806    #[test]
807    fn propagate_half_period_flips_position() {
808        let el = OrbitalElements::new(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0).unwrap();
809        let (r0, _) = el.state_vector();
810        let advanced = el.propagate(el.period() / 2.0);
811        let (r1, _) = advanced.state_vector();
812        assert_abs_diff_eq!(r1[0], -r0[0], epsilon = 1e-6);
813        assert_abs_diff_eq!(r1[1], -r0[1], epsilon = 1e-6);
814        assert_abs_diff_eq!(r1[2], -r0[2], epsilon = 1e-6);
815    }
816
817    #[test]
818    fn round_trip_elements() {
819        let el = OrbitalElements::new(2.0, 0.3, 0.4, 0.2, 0.1, 0.5, 1.0).unwrap();
820        let (r, v) = el.state_vector();
821        let recovered = OrbitalElements::from_state(&r, &v, 1.0).unwrap();
822        assert_abs_diff_eq!(recovered.a, el.a, epsilon = 1e-6);
823        assert_abs_diff_eq!(recovered.e, el.e, epsilon = 1e-6);
824        assert_abs_diff_eq!(recovered.i, el.i, epsilon = 1e-6);
825    }
826
827    #[test]
828    fn propagate_zero_is_identity() {
829        let el = OrbitalElements::new(2.0, 0.3, 0.4, 0.2, 0.1, 0.5, 1.0).unwrap();
830        let advanced = el.propagate(0.0);
831        // Compare canonical [0, 2π) representations.
832        let n0 = el.nu.rem_euclid(2.0 * std::f64::consts::PI);
833        let n1 = advanced.nu.rem_euclid(2.0 * std::f64::consts::PI);
834        assert_abs_diff_eq!(n0, n1, epsilon = 1e-9);
835    }
836
837    #[test]
838    fn invalid_elements_rejected() {
839        assert!(OrbitalElements::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0).is_err());
840        assert!(OrbitalElements::new(1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0).is_err());
841        assert!(OrbitalElements::new(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0).is_err());
842        assert!(OrbitalElements::new(1.0, 0.0, f64::NAN, 0.0, 0.0, 0.0, 1.0).is_err());
843    }
844
845    #[test]
846    fn degenerate_state_rejected() {
847        let pos = vec3(0.0, 0.0, 0.0);
848        let vel = vec3(1.0, 0.0, 0.0);
849        assert!(OrbitalElements::from_state(&pos, &vel, 1.0).is_err());
850    }
851
852    #[test]
853    fn solve_kepler_seed_is_accurate_at_high_eccentricity() {
854        // With the `E0 = M + e·sin(M)` seed the residual stays tiny even as
855        // e -> 1, where a bare `M` seed degrades. Check several M, e = 0.9.
856        for m in [0.1, 1.0, 2.5, 5.0] {
857            let e = 0.9;
858            let ecc = solve_kepler(m, e);
859            let residual = (ecc - e * ecc.sin() - m).abs();
860            assert!(residual < 1e-10, "M={m} residual={residual}");
861        }
862    }
863
864    #[test]
865    fn cross3_orthogonal() {
866        let a = vec3(1.0, 0.0, 0.0);
867        let b = vec3(0.0, 1.0, 0.0);
868        let c = cross3(&a, &b);
869        assert_abs_diff_eq!(c[0], 0.0, epsilon = 1e-12);
870        assert_abs_diff_eq!(c[1], 0.0, epsilon = 1e-12);
871        assert_abs_diff_eq!(c[2], 1.0, epsilon = 1e-12);
872    }
873
874    #[test]
875    fn j2_rates_match_formula() {
876        // LEO orbit: a = 7000 km, e = 0.01, i = 50 deg.
877        let el = OrbitalElements::new(7000.0, 0.01, 50.0_f64.to_radians(), 0.0, 0.0, 0.0, EARTH_MU)
878            .unwrap();
879        let (raan_dot, argp_dot) = el.j2_secular_rates(EARTH_J2, EARTH_RADIUS_EQ);
880        let n = (EARTH_MU / el.a.powi(3)).sqrt();
881        let p = el.a * (1.0 - el.e.powi(2));
882        let factor = 1.5 * n * EARTH_J2 * (EARTH_RADIUS_EQ / p).powi(2);
883        let ci = el.i.cos();
884        assert_abs_diff_eq!(raan_dot, -factor * ci, epsilon = 1e-12);
885        assert_abs_diff_eq!(
886            argp_dot,
887            0.5 * factor * (5.0 * ci * ci - 1.0),
888            epsilon = 1e-12
889        );
890    }
891
892    #[test]
893    fn j2_regresses_raan_for_prograde() {
894        // A 50° inclined LEO orbit should see its RAAN regress (decrease) over
895        // a day, by roughly the analytic secular rate.
896        let el = OrbitalElements::new(7000.0, 0.01, 50.0_f64.to_radians(), 1.0, 0.0, 0.0, EARTH_MU)
897            .unwrap();
898        let dt = 86_400.0; // one day, seconds
899        let advanced = el.propagate_j2(dt, EARTH_J2, EARTH_RADIUS_EQ);
900        let (raan_dot, _) = el.j2_secular_rates(EARTH_J2, EARTH_RADIUS_EQ);
901        let expected = (el.raan + raan_dot * dt).rem_euclid(2.0 * std::f64::consts::PI);
902        assert_abs_diff_eq!(advanced.raan, expected, epsilon = 1e-9);
903        // a, e, i are preserved by the secular model.
904        assert_abs_diff_eq!(advanced.a, el.a, epsilon = 1e-9);
905        assert_abs_diff_eq!(advanced.e, el.e, epsilon = 1e-12);
906        assert_abs_diff_eq!(advanced.i, el.i, epsilon = 1e-12);
907        // Prograde (i < 90°): RAAN regresses.
908        assert!(raan_dot < 0.0);
909    }
910
911    #[test]
912    fn atmospheric_density_reference_and_decay() {
913        assert_abs_diff_eq!(
914            atmospheric_density(EARTH_ATM_H0_KM),
915            EARTH_ATM_RHO0_KG_M3,
916            epsilon = 1e-20
917        );
918        // Monotonically decreasing with altitude.
919        let rho_low = atmospheric_density(300.0);
920        let rho_mid = atmospheric_density(400.0);
921        let rho_high = atmospheric_density(600.0);
922        assert!(rho_low > rho_mid);
923        assert!(rho_mid > rho_high);
924        assert!(rho_high > 0.0);
925    }
926
927    #[test]
928    fn drag_decay_shrinks_semi_major_axis() {
929        // ~400 km altitude near-circular LEO orbit, modest ballistic term.
930        let el = OrbitalElements::new(
931            6778.0,
932            0.001,
933            51.6_f64.to_radians(),
934            0.0,
935            0.0,
936            0.0,
937            EARTH_MU,
938        )
939        .unwrap();
940        let cd_a_over_m = 0.02; // m^2/kg
941        let da_dt = el.drag_da_dt(cd_a_over_m);
942        // Drag always removes energy: da/dt must be strictly negative.
943        assert!(da_dt < 0.0, "da_dt = {da_dt}");
944        // Order-of-magnitude sanity: decay should be small but nonzero over
945        // one day (tens of metres, not kilometres, for this modest BC).
946        let decay_per_day_km = da_dt.abs() * 86_400.0;
947        assert!(
948            decay_per_day_km > 1e-6,
949            "decay_per_day_km = {decay_per_day_km}"
950        );
951        assert!(
952            decay_per_day_km < 5.0,
953            "decay_per_day_km = {decay_per_day_km}"
954        );
955
956        let advanced = el.propagate_drag(3600.0, cd_a_over_m);
957        assert!(advanced.a < el.a);
958    }
959
960    #[test]
961    fn drag_rejects_denser_lower_orbit_faster() {
962        // A lower orbit sees denser atmosphere and should decay faster.
963        let hi = OrbitalElements::new(6978.0, 0.0, 0.5, 0.0, 0.0, 0.0, EARTH_MU).unwrap();
964        let lo = OrbitalElements::new(6678.0, 0.0, 0.5, 0.0, 0.0, 0.0, EARTH_MU).unwrap();
965        let bc = 0.02;
966        assert!(lo.drag_da_dt(bc).abs() > hi.drag_da_dt(bc).abs());
967    }
968
969    #[test]
970    fn shadow_function_eclipses_only_behind_earth() {
971        let sun_pos = vec3(ASTRONOMICAL_UNIT_KM, 0.0, 0.0);
972        // Satellite on the sunward side: never eclipsed.
973        let lit = vec3(7000.0, 0.0, 0.0);
974        assert!(!in_earth_shadow(&lit, &sun_pos, EARTH_RADIUS_EQ));
975        // Satellite directly behind Earth, within the shadow cylinder.
976        let eclipsed = vec3(-7000.0, 0.0, 0.0);
977        assert!(in_earth_shadow(&eclipsed, &sun_pos, EARTH_RADIUS_EQ));
978        // Satellite behind Earth but offset far enough to miss the cylinder.
979        let grazing = vec3(-7000.0, 20_000.0, 0.0);
980        assert!(!in_earth_shadow(&grazing, &sun_pos, EARTH_RADIUS_EQ));
981    }
982
983    #[test]
984    fn srp_acceleration_vector_zero_in_shadow() {
985        let sun_pos = vec3(ASTRONOMICAL_UNIT_KM, 0.0, 0.0);
986        // Circular orbit; nu = pi puts the satellite on the -x side (opposite
987        // the Sun), i.e. in eclipse for this equatorial, zero-inclination case.
988        let el = OrbitalElements::new(7000.0, 0.0, 0.0, 0.0, 0.0, std::f64::consts::PI, EARTH_MU)
989            .unwrap();
990        let a_shadow = el.srp_acceleration_vector(1.5, 0.02, &sun_pos);
991        assert_abs_diff_eq!(a_shadow.norm(), 0.0, epsilon = 1e-30);
992
993        // nu = 0 puts the satellite on the +x (sunward) side: lit, nonzero SRP.
994        let el_lit = OrbitalElements::new(7000.0, 0.0, 0.0, 0.0, 0.0, 0.0, EARTH_MU).unwrap();
995        let a_lit = el_lit.srp_acceleration_vector(1.5, 0.02, &sun_pos);
996        assert!(a_lit.norm() > 0.0);
997        // Force points away from the Sun: the satellite sits on the +x
998        // (sunward) side here, so the anti-sunward push is in -x.
999        assert!(a_lit[0] < 0.0);
1000    }
1001
1002    #[test]
1003    fn srp_magnitude_matches_cannonball_formula() {
1004        let a = srp_acceleration(1.0, 0.02, ASTRONOMICAL_UNIT_KM);
1005        let expected_m_s2 = SOLAR_PRESSURE_1AU * 1.0 * 0.02;
1006        assert_abs_diff_eq!(a * 1000.0, expected_m_s2, epsilon = 1e-15);
1007    }
1008
1009    #[test]
1010    fn third_body_conserves_kozai_integral_rate() {
1011        // theta = (1-e^2)cos^2(i) should be stationary under (i_dot, e_dot)
1012        // alone (dtheta/dt = 0), a strong self-consistency check on the
1013        // derived Lagrange-planetary-equation rates independent of overall
1014        // sign convention.
1015        let el = OrbitalElements::new(
1016            42_164.0,
1017            0.3,
1018            60.0_f64.to_radians(),
1019            0.0,
1020            30.0_f64.to_radians(),
1021            0.0,
1022            EARTH_MU,
1023        )
1024        .unwrap();
1025        let (_, _, i_dot, e_dot) = el.third_body_secular_rates(MOON_MU, MOON_DISTANCE_KM);
1026        let ci = el.i.cos();
1027        let si = el.i.sin();
1028        let theta_dot = -2.0 * el.e * e_dot * ci * ci - (1.0 - el.e * el.e) * 2.0 * ci * si * i_dot;
1029        assert_abs_diff_eq!(theta_dot, 0.0, epsilon = 1e-18);
1030    }
1031
1032    #[test]
1033    fn third_body_rates_are_finite_and_propagation_preserves_a() {
1034        let el = OrbitalElements::new(
1035            42_164.0,
1036            0.1,
1037            20.0_f64.to_radians(),
1038            0.5,
1039            0.7,
1040            0.0,
1041            EARTH_MU,
1042        )
1043        .unwrap();
1044        let (raan_dot, argp_dot, i_dot, e_dot) =
1045            el.third_body_secular_rates(SUN_MU, ASTRONOMICAL_UNIT_KM);
1046        for v in [raan_dot, argp_dot, i_dot, e_dot] {
1047            assert!(v.is_finite());
1048        }
1049        let advanced = el
1050            .propagate_third_body(3600.0, SUN_MU, ASTRONOMICAL_UNIT_KM)
1051            .unwrap();
1052        assert_abs_diff_eq!(advanced.a, el.a, epsilon = 1e-9);
1053    }
1054
1055    #[test]
1056    fn j4_rates_are_small_relative_correction_to_j2() {
1057        // LEO orbit: a = 7000 km, e = 0.01, i = 50 deg.
1058        let el = OrbitalElements::new(7000.0, 0.01, 50.0_f64.to_radians(), 0.0, 0.0, 0.0, EARTH_MU)
1059            .unwrap();
1060        let (raan_dot_j2, argp_dot_j2) = el.j2_secular_rates(EARTH_J2, EARTH_RADIUS_EQ);
1061        let (raan_dot_j24, argp_dot_j24) = el.j4_secular_rates(EARTH_J2, EARTH_J4, EARTH_RADIUS_EQ);
1062
1063        // Both finite and the J4 correction is a small fraction of the J2
1064        // term (order (J4/J2)*(Req/p)^2 << 1), not a dominant contribution.
1065        assert!(raan_dot_j24.is_finite());
1066        assert!(argp_dot_j24.is_finite());
1067        let raan_j4_only = raan_dot_j24 - raan_dot_j2;
1068        let argp_j4_only = argp_dot_j24 - argp_dot_j2;
1069        assert!(raan_j4_only.abs() > 0.0);
1070        assert!(raan_j4_only.abs() < 0.05 * raan_dot_j2.abs());
1071        assert!(
1072            argp_j4_only.abs() < 0.05 * argp_dot_j2.abs().max(1e-30) || argp_dot_j2.abs() < 1e-30
1073        );
1074    }
1075
1076    #[test]
1077    fn propagate_j4_matches_secular_rates() {
1078        let el = OrbitalElements::new(7000.0, 0.01, 50.0_f64.to_radians(), 1.0, 0.0, 0.0, EARTH_MU)
1079            .unwrap();
1080        let dt = 86_400.0;
1081        let advanced = el.propagate_j4(dt, EARTH_J2, EARTH_J4, EARTH_RADIUS_EQ);
1082        let (raan_dot, _) = el.j4_secular_rates(EARTH_J2, EARTH_J4, EARTH_RADIUS_EQ);
1083        let expected = (el.raan + raan_dot * dt).rem_euclid(2.0 * std::f64::consts::PI);
1084        assert_abs_diff_eq!(advanced.raan, expected, epsilon = 1e-9);
1085        assert_abs_diff_eq!(advanced.a, el.a, epsilon = 1e-9);
1086    }
1087}