Skip to main content

nyx_space/dynamics/
solid_tides.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 anise::constants::frames::SUN_J2000;
20use anise::errors::OrientationSnafu;
21use anise::prelude::Almanac;
22use serde::{Deserialize, Serialize};
23use serde_dhall::StaticType;
24use snafu::ResultExt;
25use typed_builder::TypedBuilder;
26
27use crate::cosmic::{AstroPhysicsSnafu, Epoch, Frame, Orbit};
28use crate::dynamics::{
29    AccelModel, DynamicsAlmanacSnafu, DynamicsAstroSnafu, DynamicsError, DynamicsPlanetarySnafu,
30};
31use crate::linalg::{Matrix3, U7, Vector3, Vector4};
32use hyperdual::linalg::norm;
33use hyperdual::{OHyperdual, hyperspace_from_vector};
34use std::fmt;
35use std::sync::Arc;
36
37/// `SolidTides` implements the solid tide acceleration model.
38/// It accounts for the crust deformation due to the Moon and the Sun.
39/// Formulas are based on IERS 2010 Conventions.
40#[derive(Clone, Debug, Serialize, Deserialize, StaticType, TypedBuilder)]
41pub struct SolidTides {
42    /// The body-fixed frame of the central body being deformed.
43    pub frame: Frame,
44    /// 2nd degree Love number
45    pub k2: f64,
46    /// 3rd degree Love number
47    pub k3: f64,
48    /// The collection of celestial bodies raising the tide.
49    pub perturbers: Vec<TidalPerturber>,
50}
51
52#[derive(Clone, Debug, Serialize, Deserialize, StaticType, TypedBuilder)]
53pub struct TidalPerturber {
54    /// The frame used to resolve the state of the perturber relative to central_frame.
55    pub frame: Frame,
56    /// Optimization flag: true only if (R_eq / r_j)^4 is large enough to warrant k3.
57    /// Set to True for the Earth system
58    pub compute_degree_3: bool,
59}
60
61impl TidalPerturber {
62    fn compute_pert(
63        &self,
64        epoch: Epoch,
65        tidal_model: &SolidTides,
66        almanac: &Almanac,
67        delta_c: &mut [[f64; 4]; 4],
68        delta_s: &mut [[f64; 4]; 4],
69    ) -> Result<(), DynamicsError> {
70        let radius_km = almanac
71            .transform(self.frame, tidal_model.frame, epoch, None)
72            .context(DynamicsAlmanacSnafu {
73                action: "Moon position in ECEF",
74            })?
75            .radius_km;
76
77        let r_body = radius_km.norm();
78        let s_body = radius_km.x / r_body;
79        let t_body = radius_km.y / r_body;
80        let u_body = radius_km.z / r_body;
81
82        let sin_phi = u_body;
83        let cos_phi = (1.0 - sin_phi.powi(2)).max(0.0).sqrt();
84        let cos_lambda = if cos_phi > 1e-12 {
85            s_body / cos_phi
86        } else {
87            1.0
88        };
89        let sin_lambda = if cos_phi > 1e-12 {
90            t_body / cos_phi
91        } else {
92            0.0
93        };
94
95        // Fully normalized Associated Legendre Polynomials P_nm(sin_phi) for n=2,3
96        let p20 = 0.5 * (3.0 * sin_phi.powi(2) - 1.0) * 5.0f64.sqrt();
97        let p21 = 3.0 * sin_phi * cos_phi * (5.0 / 3.0f64).sqrt();
98        let p22 = 3.0 * cos_phi.powi(2) * (5.0 / 12.0f64).sqrt();
99
100        let p30 = 0.5 * (5.0 * sin_phi.powi(3) - 3.0 * sin_phi) * 7.0f64.sqrt();
101        let p31 = 1.5 * (5.0 * sin_phi.powi(2) - 1.0) * cos_phi * (7.0 / 6.0f64).sqrt();
102        let p32 = 15.0 * sin_phi * cos_phi.powi(2) * (7.0 / 60.0f64).sqrt();
103        let p33 = 15.0 * cos_phi.powi(3) * (7.0 / 360.0f64).sqrt();
104
105        let primary_mu_km3_s2 = tidal_model
106            .frame
107            .mu_km3_s2()
108            .context(AstroPhysicsSnafu)
109            .context(DynamicsAstroSnafu)?;
110
111        let primary_eq_radius_km = tidal_model
112            .frame
113            .mean_equatorial_radius_km()
114            .context(AstroPhysicsSnafu)
115            .context(DynamicsAstroSnafu)?;
116
117        let secondary_mu_km3_s2 = self
118            .frame
119            .mu_km3_s2()
120            .context(AstroPhysicsSnafu)
121            .context(DynamicsAstroSnafu)?;
122
123        let gm_ratio = secondary_mu_km3_s2 / primary_mu_km3_s2;
124        let r_ratio = primary_eq_radius_km / r_body;
125
126        let m = if self.compute_degree_3 { 3 } else { 2 };
127
128        for n in 2..=m {
129            let kn = if n == 2 {
130                tidal_model.k2
131            } else {
132                tidal_model.k3
133            };
134            let common = kn / (2.0 * n as f64 + 1.0) * gm_ratio * r_ratio.powi(n as i32 + 1);
135
136            for m in 0..=n {
137                let p_nm = match (n, m) {
138                    (2, 0) => p20,
139                    (2, 1) => p21,
140                    (2, 2) => p22,
141                    (3, 0) => p30,
142                    (3, 1) => p31,
143                    (3, 2) => p32,
144                    (3, 3) => p33,
145                    _ => 0.0,
146                };
147
148                let (cos_ml, sin_ml) = match m {
149                    0 => (1.0, 0.0),
150                    1 => (cos_lambda, sin_lambda),
151                    2 => (
152                        cos_lambda.powi(2) - sin_lambda.powi(2),
153                        2.0 * sin_lambda * cos_lambda,
154                    ),
155                    3 => (
156                        cos_lambda * (cos_lambda.powi(2) - 3.0 * sin_lambda.powi(2)),
157                        sin_lambda * (3.0 * cos_lambda.powi(2) - sin_lambda.powi(2)),
158                    ),
159                    _ => (0.0, 0.0),
160                };
161
162                delta_c[n][m] += common * p_nm * cos_ml;
163                delta_s[n][m] += common * p_nm * sin_ml;
164            }
165        }
166
167        Ok(())
168    }
169}
170
171impl SolidTides {
172    /// Initializes solid tides with the Moon and the Sun, where the k3 is only computed for the Moon.
173    /// Sets the k2 Love number to 0.3019 and the k3 Love number to 0.093
174    pub fn earth_moon_system(
175        mut earth_frame: Frame,
176        mut moon_frame: Frame,
177        almanac: Arc<Almanac>,
178    ) -> Result<Arc<Self>, DynamicsError> {
179        let mut sun_j2k = almanac
180            .frame_info(SUN_J2000)
181            .context(DynamicsPlanetarySnafu {
182                action: "fetching sun frame",
183            })?;
184
185        // Repeat for the Earth and Moon
186        for frame in [&mut earth_frame, &mut moon_frame, &mut sun_j2k] {
187            if frame.mu_km3_s2.is_none() {
188                *frame = almanac.frame_info(*frame).context(DynamicsPlanetarySnafu {
189                    action: "fetching sun frame",
190                })?;
191            }
192
193            // Ensure the gravitational parameter is set.
194            frame
195                .mu_km3_s2()
196                .context(AstroPhysicsSnafu)
197                .context(DynamicsAstroSnafu)?;
198
199            // Ensure the equatorial radius is set.
200            frame
201                .mean_equatorial_radius_km()
202                .context(AstroPhysicsSnafu)
203                .context(DynamicsAstroSnafu)?;
204        }
205
206        let me = Self::builder()
207            .k2(0.3019)
208            .k3(0.093)
209            .frame(earth_frame)
210            .perturbers(vec![
211                TidalPerturber::builder()
212                    .frame(moon_frame)
213                    .compute_degree_3(true)
214                    .build(),
215                TidalPerturber::builder()
216                    .frame(sun_j2k)
217                    .compute_degree_3(true)
218                    .build(),
219            ])
220            .build();
221
222        Ok(Arc::new(me))
223    }
224
225    /// Internal helper to compute tidal delta coefficients
226    fn accumulate_deltas(
227        &self,
228        epoch: Epoch,
229        almanac: Arc<Almanac>,
230    ) -> Result<([[f64; 4]; 4], [[f64; 4]; 4]), DynamicsError> {
231        let mut delta_c = [[0.0f64; 4]; 4];
232        let mut delta_s = [[0.0f64; 4]; 4];
233
234        for pert in &self.perturbers {
235            pert.compute_pert(epoch, self, &almanac, &mut delta_c, &mut delta_s)?;
236        }
237
238        Ok((delta_c, delta_s))
239    }
240}
241
242#[allow(clippy::needless_range_loop)]
243impl AccelModel for SolidTides {
244    fn eom(&self, osc: &Orbit, almanac: Arc<Almanac>) -> Result<Vector3<f64>, DynamicsError> {
245        let (delta_c, delta_s) = self.accumulate_deltas(osc.epoch, almanac.clone())?;
246
247        // Convert the osculating orbit to the correct frame (needed for multiple harmonic fields)
248        let state = almanac
249            .transform_to(*osc, self.frame, None)
250            .context(DynamicsAlmanacSnafu {
251                action: "transforming into solid tides frame",
252            })?;
253
254        // Using the GMAT notation, with extra character for ease of highlight
255        let r_ = state.rmag_km();
256        let s_ = state.radius_km.x / r_;
257        let t_ = state.radius_km.y / r_;
258        let u_ = state.radius_km.z / r_;
259
260        // Associated Legendre polynomials a_nm (scaled as in sph_harmonics.rs)
261        let mut a_nm = [[0.0f64; 6]; 6];
262        a_nm[0][0] = 1.0;
263        for n in 1..=4 {
264            a_nm[n][n] = (1.0 + 1.0 / (2.0 * n as f64)).sqrt() * a_nm[n - 1][n - 1];
265        }
266        a_nm[1][0] = u_ * 3.0f64.sqrt();
267        for n in 1..=4 {
268            a_nm[n + 1][n] = (2.0 * n as f64 + 3.0).sqrt() * u_ * a_nm[n][n];
269        }
270
271        let b_nm = |n: usize, m: usize| {
272            (((2.0 * n as f64 + 1.0) * (2.0 * n as f64 - 1.0))
273                / ((n as f64 + m as f64) * (n as f64 - m as f64)))
274                .sqrt()
275        };
276        let c_nm = |n: usize, m: usize| {
277            (((2.0 * n as f64 + 1.0) * (n as f64 + m as f64 - 1.0) * (n as f64 - m as f64 - 1.0))
278                / ((n as f64 - m as f64) * (n as f64 + m as f64) * (2.0 * n as f64 - 3.0)))
279                .sqrt()
280        };
281
282        for m in 0..=3 {
283            for n in (m + 2)..=4 {
284                a_nm[n][m] = u_ * b_nm(n, m) * a_nm[n - 1][m] - c_nm(n, m) * a_nm[n - 2][m];
285            }
286        }
287
288        let mut r_m = [0.0f64; 4];
289        let mut i_m = [0.0f64; 4];
290        r_m[0] = 1.0;
291        i_m[0] = 0.0;
292        for m in 1..=3 {
293            r_m[m] = s_ * r_m[m - 1] - t_ * i_m[m - 1];
294            i_m[m] = s_ * i_m[m - 1] + t_ * r_m[m - 1];
295        }
296
297        let eq_radius_km = self
298            .frame
299            .mean_equatorial_radius_km()
300            .context(AstroPhysicsSnafu)
301            .context(DynamicsAstroSnafu)?;
302
303        let mu_km3_s2 = self
304            .frame
305            .mu_km3_s2()
306            .context(AstroPhysicsSnafu)
307            .context(DynamicsAstroSnafu)?;
308
309        let rho = eq_radius_km / r_;
310
311        let mut rho_np1 = mu_km3_s2 / r_ * rho;
312        let mut accel4 = Vector4::zeros();
313
314        let vr01 = |n: usize, m: usize| {
315            let mut val = ((n as f64 - m as f64) * (n as f64 + m as f64 + 1.0)).sqrt();
316            if m == 0 {
317                val /= 2.0f64.sqrt();
318            }
319            val
320        };
321        let vr11 = |n: usize, m: usize| {
322            let mut val = (((2.0 * n as f64 + 1.0)
323                * (n as f64 + m as f64 + 2.0)
324                * (n as f64 + m as f64 + 1.0))
325                / (2.0 * n as f64 + 3.0))
326                .sqrt();
327            if m == 0 {
328                val /= 2.0f64.sqrt();
329            }
330            val
331        };
332
333        let sqrt2 = 2.0f64.sqrt();
334
335        for n in 1..=3 {
336            rho_np1 *= rho;
337            if n < 2 {
338                continue;
339            } // only degree 2 and 3
340
341            let mut sum = Vector4::zeros();
342            for m in 0..=n {
343                let c_val = delta_c[n][m];
344                let s_val = delta_s[n][m];
345
346                let d_ = (c_val * r_m[m] + s_val * i_m[m]) * sqrt2;
347                let e_ = if m == 0 {
348                    0.0
349                } else {
350                    (c_val * r_m[m - 1] + s_val * i_m[m - 1]) * sqrt2
351                };
352                let f_ = if m == 0 {
353                    0.0
354                } else {
355                    (s_val * r_m[m - 1] - c_val * i_m[m - 1]) * sqrt2
356                };
357
358                sum.x += (m as f64) * a_nm[n][m] * e_;
359                sum.y += (m as f64) * a_nm[n][m] * f_;
360                sum.z += vr01(n, m) * a_nm[n][m + 1] * d_;
361                sum.w -= vr11(n, m) * a_nm[n + 1][m + 1] * d_;
362            }
363            accel4 += (rho_np1 / eq_radius_km) * sum;
364        }
365
366        let accel_ecef = Vector3::new(
367            accel4.x + accel4.w * s_,
368            accel4.y + accel4.w * t_,
369            accel4.z + accel4.w * u_,
370        );
371
372        let dcm = almanac
373            .rotate(self.frame, osc.frame, osc.epoch)
374            .context(OrientationSnafu {
375                action: "rotating accel back to integration frame",
376            })
377            .context(DynamicsAlmanacSnafu {
378                action: "rotating accel back to integration frame",
379            })?
380            .rot_mat;
381
382        Ok(dcm * accel_ecef)
383    }
384
385    fn gradient(
386        &self,
387        osc: &Orbit,
388        almanac: Arc<Almanac>,
389    ) -> Result<(Vector3<f64>, Matrix3<f64>), DynamicsError> {
390        let (delta_c, delta_s) = self.accumulate_deltas(osc.epoch, almanac.clone())?;
391
392        // Convert the osculating orbit to the correct frame (needed for multiple harmonic fields)
393        let state = almanac
394            .transform_to(*osc, self.frame, None)
395            .context(DynamicsAlmanacSnafu {
396                action: "transforming into gravity field frame",
397            })?;
398
399        let radius: Vector3<OHyperdual<f64, U7>> = hyperspace_from_vector(&state.radius_km);
400
401        // Using the GMAT notation, with extra character for ease of highlight
402        let r_ = norm(&radius);
403        let s_ = radius[0] / r_;
404        let t_ = radius[1] / r_;
405        let u_ = radius[2] / r_;
406
407        // Legendre polynomials recursion in Hyperdual
408        let mut a_nm = [[OHyperdual::<f64, U7>::from(0.0); 6]; 6];
409        a_nm[0][0] = OHyperdual::from(1.0);
410        for n in 1..=4 {
411            a_nm[n][n] =
412                OHyperdual::from((1.0 + 1.0 / (2.0 * n as f64)).sqrt()) * a_nm[n - 1][n - 1];
413        }
414        a_nm[1][0] = u_ * OHyperdual::from(3.0f64.sqrt());
415        for n in 1..=4 {
416            a_nm[n + 1][n] = OHyperdual::from((2.0 * n as f64 + 3.0).sqrt()) * u_ * a_nm[n][n];
417        }
418
419        let b_nm = |n: usize, m: usize| {
420            (((2.0 * n as f64 + 1.0) * (2.0 * n as f64 - 1.0))
421                / ((n as f64 + m as f64) * (n as f64 - m as f64)))
422                .sqrt()
423        };
424        let c_nm = |n: usize, m: usize| {
425            (((2.0 * n as f64 + 1.0) * (n as f64 + m as f64 - 1.0) * (n as f64 - m as f64 - 1.0))
426                / ((n as f64 - m as f64) * (n as f64 + m as f64) * (2.0 * n as f64 - 3.0)))
427                .sqrt()
428        };
429
430        for m in 0..=3 {
431            for n in (m + 2)..=4 {
432                a_nm[n][m] = u_ * OHyperdual::from(b_nm(n, m)) * a_nm[n - 1][m]
433                    - OHyperdual::from(c_nm(n, m)) * a_nm[n - 2][m];
434            }
435        }
436
437        let mut r_m = [OHyperdual::<f64, U7>::from(0.0); 4];
438        let mut i_m = [OHyperdual::<f64, U7>::from(0.0); 4];
439        r_m[0] = OHyperdual::from(1.0);
440        i_m[0] = OHyperdual::from(0.0);
441        for m in 1..=3 {
442            r_m[m] = s_ * r_m[m - 1] - t_ * i_m[m - 1];
443            i_m[m] = s_ * i_m[m - 1] + t_ * r_m[m - 1];
444        }
445
446        let real_eq_radius_km = self
447            .frame
448            .mean_equatorial_radius_km()
449            .context(AstroPhysicsSnafu)
450            .context(DynamicsAstroSnafu)?;
451
452        let real_mu_km3_s2 = self
453            .frame
454            .mu_km3_s2()
455            .context(AstroPhysicsSnafu)
456            .context(DynamicsAstroSnafu)?;
457
458        let eq_radius = OHyperdual::<f64, U7>::from(real_eq_radius_km);
459        let rho = eq_radius / r_;
460        let mut rho_np1 = OHyperdual::<f64, U7>::from(real_mu_km3_s2) / r_ * rho;
461
462        let mut a0 = OHyperdual::<f64, U7>::from(0.0);
463        let mut a1 = OHyperdual::<f64, U7>::from(0.0);
464        let mut a2 = OHyperdual::<f64, U7>::from(0.0);
465        let mut a3 = OHyperdual::<f64, U7>::from(0.0);
466
467        let vr01 = |n: usize, m: usize| {
468            let mut val = ((n as f64 - m as f64) * (n as f64 + m as f64 + 1.0)).sqrt();
469            if m == 0 {
470                val /= 2.0f64.sqrt();
471            }
472            val
473        };
474        let vr11 = |n: usize, m: usize| {
475            let mut val = (((2.0 * n as f64 + 1.0)
476                * (n as f64 + m as f64 + 2.0)
477                * (n as f64 + m as f64 + 1.0))
478                / (2.0 * n as f64 + 3.0))
479                .sqrt();
480            if m == 0 {
481                val /= 2.0f64.sqrt();
482            }
483            val
484        };
485        let sqrt2 = OHyperdual::<f64, U7>::from(2.0f64.sqrt());
486
487        for n in 1..=3 {
488            rho_np1 *= rho;
489            if n < 2 {
490                continue;
491            }
492
493            let mut sum0 = OHyperdual::from(0.0);
494            let mut sum1 = OHyperdual::from(0.0);
495            let mut sum2 = OHyperdual::from(0.0);
496            let mut sum3 = OHyperdual::from(0.0);
497
498            for m in 0..=n {
499                let c_val = OHyperdual::from(delta_c[n][m]);
500                let s_val = OHyperdual::from(delta_s[n][m]);
501
502                let d_ = (c_val * r_m[m] + s_val * i_m[m]) * sqrt2;
503                let e_ = if m == 0 {
504                    OHyperdual::from(0.0)
505                } else {
506                    (c_val * r_m[m - 1] + s_val * i_m[m - 1]) * sqrt2
507                };
508                let f_ = if m == 0 {
509                    OHyperdual::from(0.0)
510                } else {
511                    (s_val * r_m[m - 1] - c_val * i_m[m - 1]) * sqrt2
512                };
513
514                sum0 += OHyperdual::from(m as f64) * a_nm[n][m] * e_;
515                sum1 += OHyperdual::from(m as f64) * a_nm[n][m] * f_;
516                sum2 += OHyperdual::from(vr01(n, m)) * a_nm[n][m + 1] * d_;
517                sum3 += OHyperdual::from(vr11(n, m)) * a_nm[n + 1][m + 1] * d_;
518            }
519            let rr = rho_np1 / eq_radius;
520            a0 += rr * sum0;
521            a1 += rr * sum1;
522            a2 += rr * sum2;
523            a3 -= rr * sum3;
524        }
525
526        let accel_local = Vector3::new(a0 + a3 * s_, a1 + a3 * t_, a2 + a3 * u_);
527
528        let dcm = almanac
529            .rotate(self.frame, osc.frame, osc.epoch)
530            .context(OrientationSnafu {
531                action: "rotating accel back to integration frame",
532            })
533            .context(DynamicsAlmanacSnafu {
534                action: "rotating accel back to integration frame",
535            })?
536            .rot_mat;
537
538        let dx = dcm
539            * Vector3::new(
540                accel_local[0].real(),
541                accel_local[1].real(),
542                accel_local[2].real(),
543            );
544
545        let mut grad_local = Matrix3::zeros();
546        for i in 0..3 {
547            for j in 1..4 {
548                grad_local[(i, j - 1)] += accel_local[i][j];
549            }
550        }
551        let grad = dcm * grad_local * dcm.transpose();
552        Ok((dx, grad))
553    }
554}
555
556impl fmt::Display for SolidTides {
557    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
558        write!(f, "Solid tides for {}", self.frame)
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use crate::cosmic::Orbit;
566    use anise::constants::frames::{EARTH_J2000, IAU_EARTH_FRAME, IAU_MOON_FRAME};
567    use std::path::PathBuf;
568    use std::str::FromStr;
569
570    #[test]
571    fn test_solid_tides_earth() {
572        let data_folder: PathBuf = [env!("CARGO_MANIFEST_DIR"), "../data/01_planetary"]
573            .iter()
574            .collect();
575        let mut almanac = Almanac::default();
576        // Load kernels
577        almanac = almanac
578            .load(data_folder.join("de440s.bsp").to_str().unwrap())
579            .unwrap();
580        almanac = almanac
581            .load(data_folder.join("pck08.pca").to_str().unwrap())
582            .unwrap();
583        let almanac = Arc::new(almanac);
584
585        let epoch = Epoch::from_str("2024-01-01T12:00:00 UTC").unwrap();
586        let frame = Frame::from(EARTH_J2000);
587        let sc_orbit = Orbit::cartesian(7000.0, 0.0, 0.0, 0.0, 7.5, 0.0, epoch, frame);
588
589        let tides = SolidTides::earth_moon_system(IAU_EARTH_FRAME, IAU_MOON_FRAME, almanac.clone())
590            .expect("could not init solid tides");
591        let acc = tides.eom(&sc_orbit, almanac.clone()).unwrap();
592
593        println!("Solid tides acceleration: {:?}", acc);
594        // Typical solid tide acceleration for LEO is around 1e-9 to 1e-7 km/s^2
595        assert!(acc.norm() > 0.0);
596        assert!(acc.norm() < 1e-6);
597
598        let (acc_grad, grad) = tides.gradient(&sc_orbit, almanac).unwrap();
599        assert!((acc - acc_grad).norm() < 1e-12);
600        assert!(grad.norm() > 0.0);
601    }
602}