1use 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;
35
36#[cfg(feature = "python")]
37use pyo3::prelude::*;
38#[cfg(feature = "python")]
39use pyo3::types::PyType;
40
41#[derive(Clone, Debug, Serialize, Deserialize, StaticType, TypedBuilder, PartialEq)]
45#[cfg_attr(feature = "python", pyclass(from_py_object, get_all, set_all))]
46pub struct SolidTides {
47 pub frame: Frame,
49 pub k2: f64,
51 pub k3: f64,
53 pub perturbers: Vec<TidalPerturber>,
55}
56
57#[derive(Clone, Debug, Serialize, Deserialize, StaticType, TypedBuilder, PartialEq)]
58#[cfg_attr(feature = "python", pyclass(from_py_object, get_all, set_all))]
59pub struct TidalPerturber {
60 pub frame: Frame,
62 pub compute_degree_3: bool,
65}
66
67impl TidalPerturber {
68 fn compute_pert(
69 &self,
70 epoch: Epoch,
71 tidal_model: &SolidTides,
72 almanac: &Almanac,
73 delta_c: &mut [[f64; 4]; 4],
74 delta_s: &mut [[f64; 4]; 4],
75 ) -> Result<(), DynamicsError> {
76 let radius_km = almanac
77 .transform(self.frame, tidal_model.frame, epoch, None)
78 .context(DynamicsAlmanacSnafu {
79 action: "Moon position in ECEF",
80 })?
81 .radius_km;
82
83 let r_body = radius_km.norm();
84 let s_body = radius_km.x / r_body;
85 let t_body = radius_km.y / r_body;
86 let u_body = radius_km.z / r_body;
87
88 let sin_phi = u_body;
89 let cos_phi = (1.0 - sin_phi.powi(2)).max(0.0).sqrt();
90 let cos_lambda = if cos_phi > 1e-12 {
91 s_body / cos_phi
92 } else {
93 1.0
94 };
95 let sin_lambda = if cos_phi > 1e-12 {
96 t_body / cos_phi
97 } else {
98 0.0
99 };
100
101 let p20 = 0.5 * (3.0 * sin_phi.powi(2) - 1.0) * 5.0f64.sqrt();
103 let p21 = 3.0 * sin_phi * cos_phi * (5.0 / 3.0f64).sqrt();
104 let p22 = 3.0 * cos_phi.powi(2) * (5.0 / 12.0f64).sqrt();
105
106 let p30 = 0.5 * (5.0 * sin_phi.powi(3) - 3.0 * sin_phi) * 7.0f64.sqrt();
107 let p31 = 1.5 * (5.0 * sin_phi.powi(2) - 1.0) * cos_phi * (7.0 / 6.0f64).sqrt();
108 let p32 = 15.0 * sin_phi * cos_phi.powi(2) * (7.0 / 60.0f64).sqrt();
109 let p33 = 15.0 * cos_phi.powi(3) * (7.0 / 360.0f64).sqrt();
110
111 let primary_mu_km3_s2 = tidal_model
112 .frame
113 .mu_km3_s2()
114 .context(AstroPhysicsSnafu)
115 .context(DynamicsAstroSnafu)?;
116
117 let primary_eq_radius_km = tidal_model
118 .frame
119 .mean_equatorial_radius_km()
120 .context(AstroPhysicsSnafu)
121 .context(DynamicsAstroSnafu)?;
122
123 let secondary_mu_km3_s2 = self
124 .frame
125 .mu_km3_s2()
126 .context(AstroPhysicsSnafu)
127 .context(DynamicsAstroSnafu)?;
128
129 let gm_ratio = secondary_mu_km3_s2 / primary_mu_km3_s2;
130 let r_ratio = primary_eq_radius_km / r_body;
131
132 let m = if self.compute_degree_3 { 3 } else { 2 };
133
134 for n in 2..=m {
135 let kn = if n == 2 {
136 tidal_model.k2
137 } else {
138 tidal_model.k3
139 };
140 let common = kn / (2.0 * n as f64 + 1.0) * gm_ratio * r_ratio.powi(n as i32 + 1);
141
142 for m in 0..=n {
143 let p_nm = match (n, m) {
144 (2, 0) => p20,
145 (2, 1) => p21,
146 (2, 2) => p22,
147 (3, 0) => p30,
148 (3, 1) => p31,
149 (3, 2) => p32,
150 (3, 3) => p33,
151 _ => 0.0,
152 };
153
154 let (cos_ml, sin_ml) = match m {
155 0 => (1.0, 0.0),
156 1 => (cos_lambda, sin_lambda),
157 2 => (
158 cos_lambda.powi(2) - sin_lambda.powi(2),
159 2.0 * sin_lambda * cos_lambda,
160 ),
161 3 => (
162 cos_lambda * (cos_lambda.powi(2) - 3.0 * sin_lambda.powi(2)),
163 sin_lambda * (3.0 * cos_lambda.powi(2) - sin_lambda.powi(2)),
164 ),
165 _ => (0.0, 0.0),
166 };
167
168 delta_c[n][m] += common * p_nm * cos_ml;
169 delta_s[n][m] += common * p_nm * sin_ml;
170 }
171 }
172
173 Ok(())
174 }
175}
176
177impl SolidTides {
178 pub fn earth_moon_system(
181 mut earth_frame: Frame,
182 mut moon_frame: Frame,
183 almanac: &Almanac,
184 ) -> Result<Self, DynamicsError> {
185 let mut sun_j2k = almanac
186 .frame_info(SUN_J2000)
187 .context(DynamicsPlanetarySnafu {
188 action: "fetching sun frame",
189 })?;
190
191 for frame in [&mut earth_frame, &mut moon_frame, &mut sun_j2k] {
193 if frame.mu_km3_s2.is_none() {
194 *frame = almanac.frame_info(*frame).context(DynamicsPlanetarySnafu {
195 action: "fetching sun frame",
196 })?;
197 }
198
199 frame
201 .mu_km3_s2()
202 .context(AstroPhysicsSnafu)
203 .context(DynamicsAstroSnafu)?;
204
205 frame
207 .mean_equatorial_radius_km()
208 .context(AstroPhysicsSnafu)
209 .context(DynamicsAstroSnafu)?;
210 }
211
212 let me = Self::builder()
213 .k2(0.3019)
214 .k3(0.093)
215 .frame(earth_frame)
216 .perturbers(vec![
217 TidalPerturber::builder()
218 .frame(moon_frame)
219 .compute_degree_3(true)
220 .build(),
221 TidalPerturber::builder()
222 .frame(sun_j2k)
223 .compute_degree_3(false)
224 .build(),
225 ])
226 .build();
227
228 Ok(me)
229 }
230
231 fn accumulate_deltas(
233 &self,
234 epoch: Epoch,
235 almanac: &Almanac,
236 ) -> Result<([[f64; 4]; 4], [[f64; 4]; 4]), DynamicsError> {
237 let mut delta_c = [[0.0f64; 4]; 4];
238 let mut delta_s = [[0.0f64; 4]; 4];
239
240 for pert in &self.perturbers {
241 pert.compute_pert(epoch, self, almanac, &mut delta_c, &mut delta_s)?;
242 }
243
244 Ok((delta_c, delta_s))
245 }
246}
247
248#[allow(clippy::needless_range_loop)]
249impl AccelModel for SolidTides {
250 fn eom(&self, osc: &Orbit, almanac: &Almanac) -> Result<Vector3<f64>, DynamicsError> {
251 let (delta_c, delta_s) = self.accumulate_deltas(osc.epoch, almanac)?;
252
253 let state = almanac
255 .transform_to(*osc, self.frame, None)
256 .context(DynamicsAlmanacSnafu {
257 action: "transforming into solid tides frame",
258 })?;
259
260 let r_ = state.rmag_km();
262 let s_ = state.radius_km.x / r_;
263 let t_ = state.radius_km.y / r_;
264 let u_ = state.radius_km.z / r_;
265
266 let mut a_nm = [[0.0f64; 6]; 6];
268 a_nm[0][0] = 1.0;
269 for n in 1..=4 {
270 a_nm[n][n] = (1.0 + 1.0 / (2.0 * n as f64)).sqrt() * a_nm[n - 1][n - 1];
271 }
272 a_nm[1][0] = u_ * 3.0f64.sqrt();
273 for n in 1..=4 {
274 a_nm[n + 1][n] = (2.0 * n as f64 + 3.0).sqrt() * u_ * a_nm[n][n];
275 }
276
277 let b_nm = |n: usize, m: usize| {
278 (((2.0 * n as f64 + 1.0) * (2.0 * n as f64 - 1.0))
279 / ((n as f64 + m as f64) * (n as f64 - m as f64)))
280 .sqrt()
281 };
282 let c_nm = |n: usize, m: usize| {
283 (((2.0 * n as f64 + 1.0) * (n as f64 + m as f64 - 1.0) * (n as f64 - m as f64 - 1.0))
284 / ((n as f64 - m as f64) * (n as f64 + m as f64) * (2.0 * n as f64 - 3.0)))
285 .sqrt()
286 };
287
288 for m in 0..=3 {
289 for n in (m + 2)..=4 {
290 a_nm[n][m] = u_ * b_nm(n, m) * a_nm[n - 1][m] - c_nm(n, m) * a_nm[n - 2][m];
291 }
292 }
293
294 let mut r_m = [0.0f64; 4];
295 let mut i_m = [0.0f64; 4];
296 r_m[0] = 1.0;
297 i_m[0] = 0.0;
298 for m in 1..=3 {
299 r_m[m] = s_ * r_m[m - 1] - t_ * i_m[m - 1];
300 i_m[m] = s_ * i_m[m - 1] + t_ * r_m[m - 1];
301 }
302
303 let eq_radius_km = self
304 .frame
305 .mean_equatorial_radius_km()
306 .context(AstroPhysicsSnafu)
307 .context(DynamicsAstroSnafu)?;
308
309 let mu_km3_s2 = self
310 .frame
311 .mu_km3_s2()
312 .context(AstroPhysicsSnafu)
313 .context(DynamicsAstroSnafu)?;
314
315 let rho = eq_radius_km / r_;
316
317 let mut rho_np1 = mu_km3_s2 / r_ * rho;
318 let mut accel4 = Vector4::zeros();
319
320 let vr01 = |n: usize, m: usize| {
321 let mut val = ((n as f64 - m as f64) * (n as f64 + m as f64 + 1.0)).sqrt();
322 if m == 0 {
323 val /= 2.0f64.sqrt();
324 }
325 val
326 };
327 let vr11 = |n: usize, m: usize| {
328 let mut val = (((2.0 * n as f64 + 1.0)
329 * (n as f64 + m as f64 + 2.0)
330 * (n as f64 + m as f64 + 1.0))
331 / (2.0 * n as f64 + 3.0))
332 .sqrt();
333 if m == 0 {
334 val /= 2.0f64.sqrt();
335 }
336 val
337 };
338
339 let sqrt2 = 2.0f64.sqrt();
340
341 for n in 1..=3 {
342 rho_np1 *= rho;
343 if n < 2 {
344 continue;
345 } let mut sum = Vector4::zeros();
348 for m in 0..=n {
349 let c_val = delta_c[n][m];
350 let s_val = delta_s[n][m];
351
352 let d_ = (c_val * r_m[m] + s_val * i_m[m]) * sqrt2;
353 let e_ = if m == 0 {
354 0.0
355 } else {
356 (c_val * r_m[m - 1] + s_val * i_m[m - 1]) * sqrt2
357 };
358 let f_ = if m == 0 {
359 0.0
360 } else {
361 (s_val * r_m[m - 1] - c_val * i_m[m - 1]) * sqrt2
362 };
363
364 sum.x += (m as f64) * a_nm[n][m] * e_;
365 sum.y += (m as f64) * a_nm[n][m] * f_;
366 sum.z += vr01(n, m) * a_nm[n][m + 1] * d_;
367 sum.w -= vr11(n, m) * a_nm[n + 1][m + 1] * d_;
368 }
369 accel4 += (rho_np1 / eq_radius_km) * sum;
370 }
371
372 let accel_ecef = Vector3::new(
373 accel4.x + accel4.w * s_,
374 accel4.y + accel4.w * t_,
375 accel4.z + accel4.w * u_,
376 );
377
378 let dcm = almanac
379 .rotate(self.frame, osc.frame, osc.epoch)
380 .context(OrientationSnafu {
381 action: "rotating accel back to integration frame",
382 })
383 .context(DynamicsAlmanacSnafu {
384 action: "rotating accel back to integration frame",
385 })?
386 .rot_mat;
387
388 Ok(dcm * accel_ecef)
389 }
390
391 fn gradient(
392 &self,
393 osc: &Orbit,
394 almanac: &Almanac,
395 ) -> Result<(Vector3<f64>, Matrix3<f64>), DynamicsError> {
396 let (delta_c, delta_s) = self.accumulate_deltas(osc.epoch, almanac)?;
397
398 let state = almanac
400 .transform_to(*osc, self.frame, None)
401 .context(DynamicsAlmanacSnafu {
402 action: "transforming into gravity field frame",
403 })?;
404
405 let radius: Vector3<OHyperdual<f64, U7>> = hyperspace_from_vector(&state.radius_km);
406
407 let r_ = norm(&radius);
409 let s_ = radius[0] / r_;
410 let t_ = radius[1] / r_;
411 let u_ = radius[2] / r_;
412
413 let mut a_nm = [[OHyperdual::<f64, U7>::from(0.0); 6]; 6];
415 a_nm[0][0] = OHyperdual::from(1.0);
416 for n in 1..=4 {
417 a_nm[n][n] =
418 OHyperdual::from((1.0 + 1.0 / (2.0 * n as f64)).sqrt()) * a_nm[n - 1][n - 1];
419 }
420 a_nm[1][0] = u_ * OHyperdual::from(3.0f64.sqrt());
421 for n in 1..=4 {
422 a_nm[n + 1][n] = OHyperdual::from((2.0 * n as f64 + 3.0).sqrt()) * u_ * a_nm[n][n];
423 }
424
425 let b_nm = |n: usize, m: usize| {
426 (((2.0 * n as f64 + 1.0) * (2.0 * n as f64 - 1.0))
427 / ((n as f64 + m as f64) * (n as f64 - m as f64)))
428 .sqrt()
429 };
430 let c_nm = |n: usize, m: usize| {
431 (((2.0 * n as f64 + 1.0) * (n as f64 + m as f64 - 1.0) * (n as f64 - m as f64 - 1.0))
432 / ((n as f64 - m as f64) * (n as f64 + m as f64) * (2.0 * n as f64 - 3.0)))
433 .sqrt()
434 };
435
436 for m in 0..=3 {
437 for n in (m + 2)..=4 {
438 a_nm[n][m] = u_ * OHyperdual::from(b_nm(n, m)) * a_nm[n - 1][m]
439 - OHyperdual::from(c_nm(n, m)) * a_nm[n - 2][m];
440 }
441 }
442
443 let mut r_m = [OHyperdual::<f64, U7>::from(0.0); 4];
444 let mut i_m = [OHyperdual::<f64, U7>::from(0.0); 4];
445 r_m[0] = OHyperdual::from(1.0);
446 i_m[0] = OHyperdual::from(0.0);
447 for m in 1..=3 {
448 r_m[m] = s_ * r_m[m - 1] - t_ * i_m[m - 1];
449 i_m[m] = s_ * i_m[m - 1] + t_ * r_m[m - 1];
450 }
451
452 let real_eq_radius_km = self
453 .frame
454 .mean_equatorial_radius_km()
455 .context(AstroPhysicsSnafu)
456 .context(DynamicsAstroSnafu)?;
457
458 let real_mu_km3_s2 = self
459 .frame
460 .mu_km3_s2()
461 .context(AstroPhysicsSnafu)
462 .context(DynamicsAstroSnafu)?;
463
464 let eq_radius = OHyperdual::<f64, U7>::from(real_eq_radius_km);
465 let rho = eq_radius / r_;
466 let mut rho_np1 = OHyperdual::<f64, U7>::from(real_mu_km3_s2) / r_ * rho;
467
468 let mut a0 = OHyperdual::<f64, U7>::from(0.0);
469 let mut a1 = OHyperdual::<f64, U7>::from(0.0);
470 let mut a2 = OHyperdual::<f64, U7>::from(0.0);
471 let mut a3 = OHyperdual::<f64, U7>::from(0.0);
472
473 let vr01 = |n: usize, m: usize| {
474 let mut val = ((n as f64 - m as f64) * (n as f64 + m as f64 + 1.0)).sqrt();
475 if m == 0 {
476 val /= 2.0f64.sqrt();
477 }
478 val
479 };
480 let vr11 = |n: usize, m: usize| {
481 let mut val = (((2.0 * n as f64 + 1.0)
482 * (n as f64 + m as f64 + 2.0)
483 * (n as f64 + m as f64 + 1.0))
484 / (2.0 * n as f64 + 3.0))
485 .sqrt();
486 if m == 0 {
487 val /= 2.0f64.sqrt();
488 }
489 val
490 };
491 let sqrt2 = OHyperdual::<f64, U7>::from(2.0f64.sqrt());
492
493 for n in 1..=3 {
494 rho_np1 *= rho;
495 if n < 2 {
496 continue;
497 }
498
499 let mut sum0 = OHyperdual::from(0.0);
500 let mut sum1 = OHyperdual::from(0.0);
501 let mut sum2 = OHyperdual::from(0.0);
502 let mut sum3 = OHyperdual::from(0.0);
503
504 for m in 0..=n {
505 let c_val = OHyperdual::from(delta_c[n][m]);
506 let s_val = OHyperdual::from(delta_s[n][m]);
507
508 let d_ = (c_val * r_m[m] + s_val * i_m[m]) * sqrt2;
509 let e_ = if m == 0 {
510 OHyperdual::from(0.0)
511 } else {
512 (c_val * r_m[m - 1] + s_val * i_m[m - 1]) * sqrt2
513 };
514 let f_ = if m == 0 {
515 OHyperdual::from(0.0)
516 } else {
517 (s_val * r_m[m - 1] - c_val * i_m[m - 1]) * sqrt2
518 };
519
520 sum0 += OHyperdual::from(m as f64) * a_nm[n][m] * e_;
521 sum1 += OHyperdual::from(m as f64) * a_nm[n][m] * f_;
522 sum2 += OHyperdual::from(vr01(n, m)) * a_nm[n][m + 1] * d_;
523 sum3 += OHyperdual::from(vr11(n, m)) * a_nm[n + 1][m + 1] * d_;
524 }
525 let rr = rho_np1 / eq_radius;
526 a0 += rr * sum0;
527 a1 += rr * sum1;
528 a2 += rr * sum2;
529 a3 -= rr * sum3;
530 }
531
532 let accel_local = Vector3::new(a0 + a3 * s_, a1 + a3 * t_, a2 + a3 * u_);
533
534 let dcm = almanac
535 .rotate(self.frame, osc.frame, osc.epoch)
536 .context(OrientationSnafu {
537 action: "rotating accel back to integration frame",
538 })
539 .context(DynamicsAlmanacSnafu {
540 action: "rotating accel back to integration frame",
541 })?
542 .rot_mat;
543
544 let dx = dcm
545 * Vector3::new(
546 accel_local[0].real(),
547 accel_local[1].real(),
548 accel_local[2].real(),
549 );
550
551 let mut grad_local = Matrix3::zeros();
552 for i in 0..3 {
553 for j in 1..4 {
554 grad_local[(i, j - 1)] += accel_local[i][j];
555 }
556 }
557 let grad = dcm * grad_local * dcm.transpose();
558 Ok((dx, grad))
559 }
560}
561
562impl fmt::Display for SolidTides {
563 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
564 write!(
565 f,
566 "Solid tides for {} with {} (k2={:.6}, k3={:.6})",
567 self.frame,
568 self.perturbers
569 .iter()
570 .map(|pert| pert.to_string())
571 .collect::<Vec<String>>()
572 .join(", "),
573 self.k2,
574 self.k3
575 )
576 }
577}
578
579impl fmt::Display for TidalPerturber {
580 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
581 write!(
582 f,
583 "TidalPerturber of {} (3rd deg= {})",
584 self.frame, self.compute_degree_3
585 )
586 }
587}
588
589#[cfg(feature = "python")]
590#[cfg_attr(feature = "python", pymethods)]
591impl SolidTides {
592 #[new]
593 fn py_new(frame: Frame, k2: f64, k3: f64, perturbers: Vec<TidalPerturber>) -> Self {
594 Self {
595 frame,
596 k2,
597 k3,
598 perturbers,
599 }
600 }
601
602 #[classmethod]
603 #[pyo3(name = "earth_moon_system")]
604 fn py_earth_moon_system(
605 _cls: &Bound<'_, PyType>,
606 earth_frame: Frame,
607 moon_frame: Frame,
608 almanac: &Almanac,
609 ) -> Result<Self, DynamicsError> {
610 Self::earth_moon_system(earth_frame, moon_frame, almanac)
611 }
612
613 fn __str__(&self) -> String {
614 format!("{self}")
615 }
616
617 fn __repr__(&self) -> String {
618 format!("{self} @ {self:p}")
619 }
620
621 fn __eq__(&self, other: &Self) -> bool {
622 self == other
623 }
624}
625
626#[cfg(feature = "python")]
627#[cfg_attr(feature = "python", pymethods)]
628impl TidalPerturber {
629 #[new]
630 fn py_new(frame: Frame, compute_degree_3: bool) -> Self {
631 Self {
632 frame,
633 compute_degree_3,
634 }
635 }
636
637 fn __str__(&self) -> String {
638 format!("{self}")
639 }
640
641 fn __repr__(&self) -> String {
642 format!("{self} @ {self:p}")
643 }
644
645 fn __eq__(&self, other: &Self) -> bool {
646 self == other
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653 use crate::cosmic::Orbit;
654 use anise::constants::frames::{EARTH_J2000, IAU_EARTH_FRAME, IAU_MOON_FRAME};
655 use std::path::PathBuf;
656 use std::str::FromStr;
657
658 #[test]
659 fn test_solid_tides_earth() {
660 let data_folder: PathBuf = [env!("CARGO_MANIFEST_DIR"), "../data/01_planetary"]
661 .iter()
662 .collect();
663 let mut almanac = Almanac::default();
664 almanac = almanac
666 .load(data_folder.join("de440s.bsp").to_str().unwrap())
667 .unwrap();
668 almanac = almanac
669 .load(data_folder.join("pck08.pca").to_str().unwrap())
670 .unwrap();
671
672 let epoch = Epoch::from_str("2024-01-01T12:00:00 UTC").unwrap();
673 let sc_orbit = Orbit::cartesian(7000.0, 0.0, 0.0, 0.0, 7.5, 0.0, epoch, EARTH_J2000);
674
675 let tides =
676 SolidTides::earth_moon_system(IAU_EARTH_FRAME, IAU_MOON_FRAME, &almanac.clone())
677 .expect("could not init solid tides");
678 let acc = tides.eom(&sc_orbit, &almanac).unwrap();
679
680 println!("Solid tides acceleration: {:?}", acc);
681 assert!(acc.norm() > 0.0);
683 assert!(acc.norm() < 1e-6);
684
685 let (acc_grad, grad) = tides.gradient(&sc_orbit, &almanac).unwrap();
686 assert!((acc - acc_grad).norm() < 1e-12);
687 assert!(grad.norm() > 0.0);
688 }
689}