1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct ZonalDegrees {
22 pub j2: bool,
24 pub j3: bool,
26 pub j4: bool,
28 pub j5: bool,
30 pub j6: bool,
32}
33
34impl ZonalDegrees {
35 pub const NONE: Self = Self {
37 j2: false,
38 j3: false,
39 j4: false,
40 j5: false,
41 j6: false,
42 };
43
44 pub const J2_ONLY: Self = Self {
46 j2: true,
47 j3: false,
48 j4: false,
49 j5: false,
50 j6: false,
51 };
52
53 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 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 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#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct ZonalCoefficients {
102 pub j2: f64,
104 pub j3: f64,
106 pub j4: f64,
108 pub j5: f64,
110 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#[derive(Debug, Clone, Copy, PartialEq)]
141pub struct ZonalGravity {
142 pub mu_km3_s2: f64,
144 pub re_km: f64,
146 pub degrees: ZonalDegrees,
148 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 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 pub fn earth_j2_through_j6() -> Self {
181 Self::default()
182 }
183
184 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 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}