Skip to main content

sidereon_core/astro/forces/
zonal.rs

1//! Axisymmetric Earth zonal harmonic gravity through degree 6.
2//!
3//! The acceleration is formed from the zonal part of the geopotential
4//! `U_n = -mu * J_n * Re^n * P_n(z/r) / r^(n+1)`, using the closed-form
5//! Legendre polynomials listed by Montenbruck & Gill, "Satellite Orbits",
6//! section 3.2, and Vallado, "Fundamentals of Astrodynamics and Applications",
7//! section 8.7. The Earth pole is the inertial +Z axis, matching the existing
8//! J2 force model.
9
10use crate::astro::constants::{
11    J2_EARTH, J3_EARTH, J4_EARTH, J5_EARTH, J6_EARTH, MU_EARTH, RE_EARTH,
12};
13use crate::astro::error::PropagationError;
14use crate::astro::forces::r#trait::ForceModel;
15use crate::astro::propagator::api::PropagationContext;
16use crate::astro::state::CartesianState;
17use nalgebra::Vector3;
18
19/// Active zonal harmonic degrees for [`ZonalGravity`].
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct ZonalDegrees {
22    /// Include the J2 oblateness term.
23    pub j2: bool,
24    /// Include the J3 odd zonal term.
25    pub j3: bool,
26    /// Include the J4 zonal term.
27    pub j4: bool,
28    /// Include the J5 zonal term.
29    pub j5: bool,
30    /// Include the J6 zonal term.
31    pub j6: bool,
32}
33
34impl ZonalDegrees {
35    /// No zonal degrees.
36    pub const NONE: Self = Self {
37        j2: false,
38        j3: false,
39        j4: false,
40        j5: false,
41        j6: false,
42    };
43
44    /// J2 only.
45    pub const J2_ONLY: Self = Self {
46        j2: true,
47        j3: false,
48        j4: false,
49        j5: false,
50        j6: false,
51    };
52
53    /// J2 through J6.
54    pub const J2_THROUGH_J6: Self = Self {
55        j2: true,
56        j3: true,
57        j4: true,
58        j5: true,
59        j6: true,
60    };
61
62    /// J3 through J6, useful when layering on the legacy [`crate::astro::forces::J2Gravity`].
63    pub const J3_THROUGH_J6: Self = Self {
64        j2: false,
65        j3: true,
66        j4: true,
67        j5: true,
68        j6: true,
69    };
70
71    /// Build a consecutive active set from J2 through `max_degree`.
72    pub fn through(max_degree: u8) -> Result<Self, PropagationError> {
73        if !(2..=6).contains(&max_degree) {
74            return Err(PropagationError::InvalidInput(
75                "zonal max_degree must be in 2..=6".to_string(),
76            ));
77        }
78        Ok(Self {
79            j2: max_degree >= 2,
80            j3: max_degree >= 3,
81            j4: max_degree >= 4,
82            j5: max_degree >= 5,
83            j6: max_degree >= 6,
84        })
85    }
86
87    fn is_active(self, degree: u8) -> bool {
88        match degree {
89            2 => self.j2,
90            3 => self.j3,
91            4 => self.j4,
92            5 => self.j5,
93            6 => self.j6,
94            _ => false,
95        }
96    }
97}
98
99/// Unnormalized zonal harmonic coefficients through degree 6.
100#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct ZonalCoefficients {
102    /// J2 coefficient, dimensionless.
103    pub j2: f64,
104    /// J3 coefficient, dimensionless.
105    pub j3: f64,
106    /// J4 coefficient, dimensionless.
107    pub j4: f64,
108    /// J5 coefficient, dimensionless.
109    pub j5: f64,
110    /// J6 coefficient, dimensionless.
111    pub j6: f64,
112}
113
114impl Default for ZonalCoefficients {
115    fn default() -> Self {
116        Self {
117            j2: J2_EARTH,
118            j3: J3_EARTH,
119            j4: J4_EARTH,
120            j5: J5_EARTH,
121            j6: J6_EARTH,
122        }
123    }
124}
125
126impl ZonalCoefficients {
127    fn get(self, degree: u8) -> f64 {
128        match degree {
129            2 => self.j2,
130            3 => self.j3,
131            4 => self.j4,
132            5 => self.j5,
133            6 => self.j6,
134            _ => 0.0,
135        }
136    }
137}
138
139/// Closed-form zonal gravity perturbation through degree 6.
140#[derive(Debug, Clone, Copy, PartialEq)]
141pub struct ZonalGravity {
142    /// Gravitational parameter, km^3/s^2.
143    pub mu_km3_s2: f64,
144    /// Reference equatorial radius, km.
145    pub re_km: f64,
146    /// Active zonal degrees.
147    pub degrees: ZonalDegrees,
148    /// Unnormalized zonal coefficients.
149    pub coefficients: ZonalCoefficients,
150}
151
152impl Default for ZonalGravity {
153    fn default() -> Self {
154        Self {
155            mu_km3_s2: MU_EARTH,
156            re_km: RE_EARTH,
157            degrees: ZonalDegrees::J2_THROUGH_J6,
158            coefficients: ZonalCoefficients::default(),
159        }
160    }
161}
162
163impl ZonalGravity {
164    /// Build zonal gravity with explicit parameters.
165    pub fn new(
166        mu_km3_s2: f64,
167        re_km: f64,
168        degrees: ZonalDegrees,
169        coefficients: ZonalCoefficients,
170    ) -> Self {
171        Self {
172            mu_km3_s2,
173            re_km,
174            degrees,
175            coefficients,
176        }
177    }
178
179    /// Earth J2 through J6 zonal gravity using the crate constants.
180    pub fn earth_j2_through_j6() -> Self {
181        Self::default()
182    }
183
184    /// Earth J3 through J6 zonal gravity using the crate constants.
185    pub fn earth_j3_through_j6() -> Self {
186        Self {
187            degrees: ZonalDegrees::J3_THROUGH_J6,
188            ..Self::default()
189        }
190    }
191}
192
193impl ForceModel for ZonalGravity {
194    fn acceleration(
195        &self,
196        state: &CartesianState,
197        _ctx: &PropagationContext,
198    ) -> Result<Vector3<f64>, PropagationError> {
199        let r_mag2 = state.position_km.norm_squared();
200        if r_mag2 == 0.0 {
201            return Err(PropagationError::NumericalFailure(
202                "Zero position magnitude".to_string(),
203            ));
204        }
205        let r_mag = r_mag2.sqrt();
206        let s = state.position_km.z / r_mag;
207
208        let mut accel = Vector3::zeros();
209        for degree in 2_u8..=6 {
210            if self.degrees.is_active(degree) {
211                accel += zonal_degree_acceleration(
212                    state.position_km,
213                    r_mag,
214                    s,
215                    degree,
216                    self.mu_km3_s2,
217                    self.re_km,
218                    self.coefficients.get(degree),
219                );
220            }
221        }
222        Ok(accel)
223    }
224}
225
226fn zonal_degree_acceleration(
227    position_km: Vector3<f64>,
228    r_mag: f64,
229    s: f64,
230    degree: u8,
231    mu_km3_s2: f64,
232    re_km: f64,
233    coefficient: f64,
234) -> Vector3<f64> {
235    let degree_i32 = i32::from(degree);
236    let degree_f64 = f64::from(degree);
237    let (p, dp) = legendre_and_derivative(degree, s);
238    let common = mu_km3_s2 * coefficient * re_km.powi(degree_i32);
239    let xy_bracket = (degree_f64 + 1.0) * p + s * dp;
240    let z_bracket = (degree_f64 + 1.0) * s * p - (1.0 - s * s) * dp;
241    let xy_scale = common / r_mag.powi(degree_i32 + 3);
242    let z_scale = common / r_mag.powi(degree_i32 + 2);
243
244    Vector3::new(
245        xy_scale * position_km.x * xy_bracket,
246        xy_scale * position_km.y * xy_bracket,
247        z_scale * z_bracket,
248    )
249}
250
251fn legendre_and_derivative(degree: u8, s: f64) -> (f64, f64) {
252    let s2 = s * s;
253    match degree {
254        2 => ((3.0 * s2 - 1.0) / 2.0, 3.0 * s),
255        3 => ((5.0 * s2 * s - 3.0 * s) / 2.0, (15.0 * s2 - 3.0) / 2.0),
256        4 => (
257            (35.0 * s2 * s2 - 30.0 * s2 + 3.0) / 8.0,
258            (35.0 * s2 * s - 15.0 * s) / 2.0,
259        ),
260        5 => (
261            (63.0 * s2 * s2 * s - 70.0 * s2 * s + 15.0 * s) / 8.0,
262            (315.0 * s2 * s2 - 210.0 * s2 + 15.0) / 8.0,
263        ),
264        6 => (
265            (231.0 * s2 * s2 * s2 - 315.0 * s2 * s2 + 105.0 * s2 - 5.0) / 16.0,
266            (693.0 * s2 * s2 * s - 630.0 * s2 * s + 105.0 * s) / 8.0,
267        ),
268        _ => (0.0, 0.0),
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    //! Clean-room validation anchors:
275    //!
276    //! The expected accelerations below are fixed values evaluated from the
277    //! closed-form zonal equations in Montenbruck & Gill, section 3.2, and
278    //! Vallado, section 8.7, at the stated Cartesian point. They are not
279    //! generated from the implementation under test. The J3..J6 constants are
280    //! unnormalized EGM96 values from the published model coefficients.
281
282    use super::*;
283
284    fn one_degree(degree: u8) -> ZonalGravity {
285        let mut degrees = ZonalDegrees::NONE;
286        match degree {
287            2 => degrees.j2 = true,
288            3 => degrees.j3 = true,
289            4 => degrees.j4 = true,
290            5 => degrees.j5 = true,
291            6 => degrees.j6 = true,
292            _ => unreachable!("test degree is in 2..=6"),
293        }
294        ZonalGravity {
295            degrees,
296            ..ZonalGravity::default()
297        }
298    }
299
300    fn test_state() -> CartesianState {
301        CartesianState::new(0.0, [7000.0, -1210.0, 1300.0], [0.0, 0.0, 0.0])
302    }
303
304    fn assert_close_vector(actual: Vector3<f64>, expected: [f64; 3], tolerance: f64) {
305        for axis in 0..3 {
306            assert!(
307                (actual[axis] - expected[axis]).abs() <= tolerance,
308                "axis {axis}: actual={} expected={}",
309                actual[axis],
310                expected[axis]
311            );
312        }
313    }
314
315    #[test]
316    fn coefficients_pin_published_egm96_values() {
317        let coefficients = ZonalCoefficients::default();
318        assert_eq!(
319            coefficients.j3.to_bits(),
320            (-2.532_656_485_332_235_5e-6_f64).to_bits()
321        );
322        assert_eq!(
323            coefficients.j4.to_bits(),
324            (-1.619_621_591_367e-6_f64).to_bits()
325        );
326        assert_eq!(
327            coefficients.j5.to_bits(),
328            (-2.272_960_828_686_982e-7_f64).to_bits()
329        );
330        assert_eq!(
331            coefficients.j6.to_bits(),
332            5.406_812_391_070_848e-7_f64.to_bits()
333        );
334    }
335
336    #[test]
337    fn each_degree_matches_closed_form_reference_value() {
338        let expected = [
339            [
340                -7.863_322_262_469_1e-6,
341                1.359_231_419_655_373e-6,
342                -4.945_691_390_909_161e-6,
343            ],
344            [
345                1.613_036_665_014_226_3e-8,
346                -2.788_249_092_381_734e-9,
347                -1.376_534_176_925_069_8e-8,
348            ],
349            [
350                -7.779_765_177_631_29e-9,
351                1.344_787_980_704_837e-9,
352                -1.084_371_965_506_775_4e-8,
353            ],
354            [
355                -1.736_874_053_306_558_4e-9,
356                3.002_310_863_572_765e-10,
357                6.722_489_204_891_498e-10,
358            ],
359            [
360                -9.402_407_445_840_708e-10,
361                1.625_273_287_066_751e-10,
362                -3.939_169_694_358_007e-9,
363            ],
364        ];
365
366        for (idx, degree) in (2_u8..=6).enumerate() {
367            let actual = one_degree(degree)
368                .acceleration(&test_state(), &PropagationContext::default())
369                .expect("zonal acceleration");
370            assert_close_vector(actual, expected[idx], 1.0e-20);
371        }
372    }
373
374    #[test]
375    fn j3_has_odd_zonal_north_south_signature() {
376        let gravity = one_degree(3);
377        let north = CartesianState::new(0.0, [7000.0, 0.0, 1300.0], [0.0, 0.0, 0.0]);
378        let south = CartesianState::new(0.0, [7000.0, 0.0, -1300.0], [0.0, 0.0, 0.0]);
379        let north_accel = gravity
380            .acceleration(&north, &PropagationContext::default())
381            .expect("north acceleration");
382        let south_accel = gravity
383            .acceleration(&south, &PropagationContext::default())
384            .expect("south acceleration");
385
386        assert!((north_accel.x + south_accel.x).abs() < 1.0e-22);
387        assert_eq!(north_accel.y, 0.0);
388        assert_eq!(south_accel.y, 0.0);
389        assert!((north_accel.z - south_accel.z).abs() < 1.0e-22);
390        assert!(north_accel.z < 0.0);
391        assert!(south_accel.z < 0.0);
392    }
393}