use crate::cio::earth_rotation_angle;
use crate::egm2008_data::{EGM2008_GM, EGM2008_RE};
use crate::ephem::{moon_position, sun_position};
use crate::fes2004_data::FES2004;
use crate::forces::{MU_MOON, MU_SUN};
use crate::gravity_sh::SphericalHarmonicField;
use crate::nutation::delaunay_args;
use crate::portable_math::PortableFloat;
use std::collections::BTreeMap;
type Vec3 = [f64; 3];
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StokesDelta {
pub n: usize,
pub m: usize,
pub dc: f64,
pub ds: f64,
}
const K20: (f64, f64) = (0.30190, 0.0);
const K21: (f64, f64) = (0.29830, -0.00144);
#[allow(clippy::approx_constant)]
const K22: (f64, f64) = (0.30102, -0.00130);
const K30: f64 = 0.093;
const K31: f64 = 0.093;
const K32: f64 = 0.093;
const K33: f64 = 0.094;
const A0: f64 = 4.4228e-8; const H0: f64 = -0.31460;
pub fn permanent_tide_c20() -> f64 {
A0 * H0 * K20.0
}
pub fn pbar(n: usize, m: usize, u: f64) -> f64 {
let w = (1.0 - u * u).max(0.0).sqrt(); match (n, m) {
(2, 0) => 5.0_f64.sqrt() * (3.0 * u * u - 1.0) / 2.0,
(2, 1) => 15.0_f64.sqrt() * u * w,
(2, 2) => 15.0_f64.sqrt() / 2.0 * (1.0 - u * u),
(3, 0) => 7.0_f64.sqrt() * (5.0 * u * u * u - 3.0 * u) / 2.0,
(3, 1) => (7.0_f64 / 6.0).sqrt() * 1.5 * (5.0 * u * u - 1.0) * w,
(3, 2) => (7.0_f64 / 60.0).sqrt() * 15.0 * u * (1.0 - u * u),
(3, 3) => (7.0_f64 / 360.0).sqrt() * 15.0 * w * w * w,
_ => 0.0,
}
}
fn love(n: usize, m: usize) -> (f64, f64) {
match (n, m) {
(2, 0) => K20,
(2, 1) => K21,
(2, 2) => K22,
(3, 0) => (K30, 0.0),
(3, 1) => (K31, 0.0),
(3, 2) => (K32, 0.0),
(3, 3) => (K33, 0.0),
_ => (0.0, 0.0),
}
}
pub fn step2_constituent(
m: usize,
a_m: f64,
dk_re: f64,
dk_im: f64,
h_f: f64,
theta_f: f64,
) -> (f64, f64) {
let amp = a_m * h_f;
let (tr, ti) = (amp * dk_re, amp * dk_im); let (s, c) = theta_f.psin_cos();
let pr = tr * c - ti * s;
let pi = tr * s + ti * c;
match m {
1 => (pi, pr),
2 => (pr, -pi),
_ => (0.0, 0.0),
}
}
pub fn solid_earth_tide_step1(jd_tt: f64) -> Vec<StokesDelta> {
let t_jc = (jd_tt - 2_451_545.0) / 36525.0;
let theta_g = earth_rotation_angle(jd_tt);
let bodies = [(sun_position(t_jc), MU_SUN), (moon_position(t_jc), MU_MOON)];
let geo: Vec<(f64, f64, f64, f64)> = bodies
.iter()
.map(|&(p, mu)| {
let r = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
let sinphi = p[2] / r;
let lambda = p[1].patan2(p[0]) - theta_g;
(sinphi, lambda, r, mu)
})
.collect();
let mut out = Vec::new();
for (n, mmax) in [(2usize, 2usize), (3, 3)] {
for m in 0..=mmax {
let (kr, ki) = love(n, m);
let mut sum_cos = 0.0;
let mut sum_sin = 0.0;
for &(sinphi, lambda, r, mu) in &geo {
let g =
(mu / EGM2008_GM) * (EGM2008_RE / r).ppowi((n + 1) as i32) * pbar(n, m, sinphi);
let ml = m as f64 * lambda;
sum_cos += g * ml.pcos();
sum_sin += g * ml.psin();
}
let f = 1.0 / (2.0 * n as f64 + 1.0);
let dc = f * (kr * sum_cos + ki * sum_sin);
let ds = f * (kr * sum_sin - ki * sum_cos);
out.push(StokesDelta { n, m, dc, ds });
}
}
out
}
pub fn doodson_args(jd_tt: f64) -> [f64; 6] {
let [l, lp, f, d, om] = delaunay_args(jd_tt);
let theta_g = earth_rotation_angle(jd_tt);
let s = f + om;
let h = s - d;
let p = s - l;
let np = -om;
let ps = h - lp;
let tau = (theta_g + std::f64::consts::PI) - s;
[tau, s, h, p, np, ps]
}
pub fn doodson_phase(mult: &[i8; 6], args: &[f64; 6]) -> f64 {
mult.iter().zip(args).map(|(&k, &a)| k as f64 * a).sum()
}
pub fn ocean_tide(jd_tt: f64) -> Vec<StokesDelta> {
let args = doodson_args(jd_tt);
let mut acc: BTreeMap<(usize, usize), (f64, f64)> = BTreeMap::new();
for &(mult, n, m, cp, sp, cm, sm) in FES2004 {
let theta = doodson_phase(&mult, &args);
let (sin_t, cos_t) = theta.psin_cos();
let dc = ((cp + cm) * cos_t + (sp + sm) * sin_t) * 1e-11;
let ds = ((cm - cp) * sin_t + (sp - sm) * cos_t) * 1e-11;
let e = acc.entry((n as usize, m as usize)).or_insert((0.0, 0.0));
e.0 += dc;
e.1 += ds;
}
acc.into_iter()
.map(|((n, m), (dc, ds))| StokesDelta { n, m, dc, ds })
.collect()
}
type S2AirRow = (u8, u8, f64, f64, f64, f64);
static S2_AIR: &[S2AirRow] = &[
(2, 0, 51.79, 324.08, 0.0, 0.0),
(2, 1, 4.08, 200.38, 20.79, 49.41),
(2, 2, 365.07, 292.85, 6.21, 292.80),
(3, 0, 36.41, 341.75, 0.0, 0.0),
(3, 1, 2.32, 230.91, 6.35, 245.30),
(3, 2, 7.80, 22.93, 3.54, 296.31),
(3, 3, 3.75, 18.18, 1.01, 288.25),
(4, 0, 16.60, 91.68, 0.0, 0.0),
(4, 1, 2.80, 327.47, 3.10, 239.41),
(4, 2, 16.43, 118.97, 2.33, 127.63),
(4, 3, 0.40, 26.49, 0.51, 338.93),
(4, 4, 0.15, 91.20, 0.07, 227.75),
];
const S2_DOODSON: [i8; 6] = [2, 2, -2, 0, 0, 0];
fn load_love(n: usize) -> f64 {
match n {
2 => -0.3075,
3 => -0.195,
4 => -0.132,
_ => 0.0,
}
}
fn full_norm(n: usize, m: usize) -> f64 {
let fact = |k: usize| (1..=k).fold(1.0_f64, |a, i| a * i as f64);
let dm = if m == 0 { 1.0 } else { 2.0 };
(dm * (2 * n + 1) as f64 * fact(n - m) / fact(n + m)).sqrt()
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct S2AirWave {
pub n: usize,
pub m: usize,
pub prograde: bool,
pub amplitude_ubar: f64,
pub epsilon_deg: f64,
}
pub fn s2_air_waves() -> Vec<S2AirWave> {
let mut out = Vec::new();
for &(n8, m8, dp, psp, dm, psm) in S2_AIR {
let (n, m) = (n8 as usize, m8 as usize);
let nf = full_norm(n, m);
for (prograde, d, psi) in [(true, dp, psp), (false, dm, psm)] {
if d != 0.0 {
out.push(S2AirWave {
n,
m,
prograde,
amplitude_ubar: d / nf,
epsilon_deg: (90.0 - psi).rem_euclid(360.0),
});
}
}
}
out
}
pub fn atmospheric_tide(jd_tt: f64) -> Vec<StokesDelta> {
const G: f64 = 6.674e-11; const GE: f64 = 9.806_65; const UBAR_TO_PA: f64 = 0.1; let args = doodson_args(jd_tt);
let (sin_t, cos_t) = doodson_phase(&S2_DOODSON, &args).psin_cos();
let mut out = Vec::new();
for &(n8, m8, dp, psp, dm, psm) in S2_AIR {
let (n, m) = (n8 as usize, m8 as usize);
let nf = full_norm(n, m);
let (cp, sp) = (
dp / nf * psp.to_radians().pcos(),
dp / nf * psp.to_radians().psin(),
);
let (cm, sm) = (
dm / nf * psm.to_radians().pcos(),
dm / nf * psm.to_radians().psin(),
);
let fac = (4.0 * std::f64::consts::PI * G / (GE * GE))
* ((1.0 + load_love(n)) / (2.0 * n as f64 + 1.0))
* UBAR_TO_PA;
let dc = fac * ((cp + cm) * cos_t + (sp + sm) * sin_t);
let ds = fac * ((cm - cp) * sin_t + (sp - sm) * cos_t);
out.push(StokesDelta { n, m, dc, ds });
}
out
}
type S1AirRow = (u8, u8, f64, f64);
static S1_AIR_N1: &[S1AirRow] = &[
(0, 0, 0.011, 295.1),
(1, 0, 0.014, 301.7),
(2, 0, 0.029, 228.6),
(3, 0, 0.019, 266.8),
(1, 1, 0.220, 73.2),
(2, 1, 0.019, 37.6),
(3, 1, 0.125, 264.8),
(4, 1, 0.016, 155.8),
(2, 2, 0.066, 37.7),
(3, 2, 0.025, 358.1),
(4, 2, 0.031, 241.7),
(5, 2, 0.003, 147.4),
(3, 3, 0.020, 235.6),
(4, 3, 0.032, 298.3),
(5, 3, 0.019, 258.3),
(6, 3, 0.011, 186.3),
(4, 4, 0.029, 336.3),
(5, 4, 0.014, 100.1),
(6, 4, 0.010, 34.8),
(7, 4, 0.011, 344.7),
(5, 5, 0.037, 327.4),
(6, 5, 0.034, 93.0),
(7, 5, 0.012, 83.9),
(8, 5, 0.007, 266.1),
];
const S1_DOODSON: [i8; 6] = [1, 1, -1, 0, 0, 1];
pub const S1_AIR_MAX_DEGREE: usize = 6;
fn load_love_iers(n: usize) -> f64 {
match n {
2 => -0.3075,
3 => -0.195,
4 => -0.132,
5 => -0.1032,
6 => -0.0892,
_ => 0.0,
}
}
pub fn atmospheric_tide_s1(jd_tt: f64) -> Vec<StokesDelta> {
const G: f64 = 6.674e-11; const GE: f64 = 9.806_65; const RHO_W: f64 = 1025.0; const CM_TO_M: f64 = 0.01;
let theta = doodson_phase(&S1_DOODSON, &doodson_args(jd_tt));
S1_AIR_N1
.iter()
.filter(|&&(n, ..)| (2..=S1_AIR_MAX_DEGREE).contains(&(n as usize)))
.map(|&(n8, m8, c_cm, eps_deg)| {
let n = n8 as usize;
let fac = (4.0 * std::f64::consts::PI * G * RHO_W / GE)
* ((1.0 + load_love_iers(n)) / (2.0 * n as f64 + 1.0))
* c_cm
* CM_TO_M;
let (sa, ca) = (theta + eps_deg.to_radians()).psin_cos();
StokesDelta {
n,
m: m8 as usize,
dc: fac * sa,
ds: fac * ca,
}
})
.collect()
}
pub fn s1_air_tide_acceleration(r_eci: Vec3, jd_tt: f64) -> Vec3 {
let theta_g = earth_rotation_angle(jd_tt);
let r_ecef = rot_eci_to_ecef(theta_g, r_eci);
let mut field = SphericalHarmonicField::zeros(EGM2008_GM, EGM2008_RE, S1_AIR_MAX_DEGREE);
for d in atmospheric_tide_s1(jd_tt) {
field.set(d.n, d.m, d.dc, d.ds);
}
rot_ecef_to_eci(theta_g, field.acceleration(r_ecef))
}
fn rot_eci_to_ecef(theta_g: f64, v: Vec3) -> Vec3 {
let (s, c) = theta_g.psin_cos();
[c * v[0] + s * v[1], -s * v[0] + c * v[1], v[2]]
}
fn rot_ecef_to_eci(theta_g: f64, v: Vec3) -> Vec3 {
let (s, c) = theta_g.psin_cos();
[c * v[0] - s * v[1], s * v[0] + c * v[1], v[2]]
}
pub fn tidal_acceleration(r_eci: Vec3, jd_tt: f64) -> Vec3 {
let theta_g = earth_rotation_angle(jd_tt);
let r_ecef = rot_eci_to_ecef(theta_g, r_eci);
let mut acc: BTreeMap<(usize, usize), (f64, f64)> = BTreeMap::new();
for d in solid_earth_tide_step1(jd_tt) {
let e = acc.entry((d.n, d.m)).or_insert((0.0, 0.0));
e.0 += d.dc;
e.1 += d.ds;
}
if let Some(e) = acc.get_mut(&(2, 0)) {
e.0 -= permanent_tide_c20();
}
for d in ocean_tide(jd_tt) {
let e = acc.entry((d.n, d.m)).or_insert((0.0, 0.0));
e.0 += d.dc;
e.1 += d.ds;
}
for d in atmospheric_tide(jd_tt) {
let e = acc.entry((d.n, d.m)).or_insert((0.0, 0.0));
e.0 += d.dc;
e.1 += d.ds;
}
let mut field = SphericalHarmonicField::zeros(EGM2008_GM, EGM2008_RE, 4);
for ((n, m), (dc, ds)) in acc {
if n >= 2 {
field.set(n, m, dc, ds);
}
}
let a_ecef = field.acceleration(r_ecef);
rot_ecef_to_eci(theta_g, a_ecef)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn doodson_k1_phase_is_theta_g_plus_pi() {
let jd = 2_453_736.5;
let got = doodson_phase(&[1, 1, 0, 0, 0, 0], &doodson_args(jd));
let want = earth_rotation_angle(jd) + std::f64::consts::PI;
let two_pi = 2.0 * std::f64::consts::PI;
let d = (got - want).rem_euclid(two_pi);
let d = d.min(two_pi - d);
assert!(
d < 1e-9,
"K1 Doodson phase {got} vs θ_g+π {want} (wrapped diff {d})"
);
}
#[test]
fn fes2004_m2_22_matches_source() {
let &(_, _, _, cp, sp, cm, sm) = FES2004
.iter()
.find(|&&(mult, n, m, ..)| mult == [2, 0, 0, 0, 0, 0] && n == 2 && m == 2)
.expect("M2 (2,2) present");
assert_eq!((cp, sp, cm, sm), (-39.36214, 46.75729, 9.57270, 5.24459));
}
#[test]
fn tidal_acceleration_is_physical_at_leo() {
let r = [7.0e6, 1.0e6, 2.0e6];
let a = tidal_acceleration(r, 2_453_736.5);
let mag = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt();
assert!(mag.is_finite(), "tidal acceleration must be finite");
assert!(
(1e-9..1e-6).contains(&mag),
"tidal accel {mag:e} m/s² outside the physical 1e-9..1e-6 band"
);
let rn = (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt();
let two_body = crate::forces::MU_EARTH / (rn * rn);
assert!(
mag < 1e-5 * two_body,
"tide should be << two-body ({mag:e} vs {two_body:e})"
);
}
#[test]
fn s1_air_tide_table_matches_biancale_bode_n1() {
assert_eq!(S1_AIR_N1.len(), 24);
let row = |n: u8, m: u8| {
S1_AIR_N1
.iter()
.find(|r| r.0 == n && r.1 == m)
.map(|r| (r.2, r.3))
};
assert_eq!(row(1, 1), Some((0.220, 73.2)));
assert_eq!(row(3, 1), Some((0.125, 264.8)));
assert_eq!(row(8, 5), Some((0.007, 266.1)));
}
#[test]
fn s1_air_tide_is_diurnal_with_its_load_factor_amplitude() {
let jd = 2_455_197.5;
let a = atmospheric_tide_s1(jd);
let b = atmospheric_tide_s1(jd + 0.25);
let c = atmospheric_tide_s1(jd + 1.0);
assert_eq!(a.len(), 24 - 3 - 3);
assert!(a.iter().all(|d| (2..=S1_AIR_MAX_DEGREE).contains(&d.n)));
for ((x, y), z) in a.iter().zip(&b).zip(&c) {
let amp = |d: &StokesDelta| d.dc.phypot(d.ds);
assert!((amp(x) - amp(y)).abs() < 1e-24 && (amp(x) - amp(z)).abs() < 1e-24);
assert!((x.dc - z.dc).abs() < 1e-3 * amp(x), "{x:?} vs {z:?}");
assert!((y.dc - x.ds).abs() < 1e-2 * amp(x), "{x:?} vs {y:?}");
assert!((y.ds + x.dc).abs() < 1e-2 * amp(x), "{x:?} vs {y:?}");
let row = S1_AIR_N1
.iter()
.find(|r| r.0 as usize == x.n && r.1 as usize == x.m)
.expect("row");
let want = 4.0 * std::f64::consts::PI * 6.674e-11 * 1025.0 / 9.806_65
* (1.0 + load_love_iers(x.n))
/ (2.0 * x.n as f64 + 1.0)
* row.2
* 0.01;
assert!((amp(x) - want).abs() < 1e-15 * want.max(1e-30) + 1e-25);
}
}
#[test]
fn s1_air_tide_has_physical_magnitude() {
let p11 = 0.220 * 3f64.sqrt() * 1025.0 * 9.80 * 0.01 / 100.0; assert!(
(0.1..2.0).contains(&p11),
"S1 (1,1) equatorial amplitude {p11} hPa"
);
let r = [7.0e6, 1.0e6, 2.0e6];
let mut worst = 0.0f64;
for k in 0..24 {
let a = s1_air_tide_acceleration(r, 2_455_197.5 + k as f64 / 24.0);
worst = worst.max((a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt());
}
assert!(
(1e-12..1e-8).contains(&worst),
"S1 air-tide acceleration {worst:e} m/s² outside 1e-12..1e-8"
);
}
#[test]
fn s2_air_tide_22_matches_ray2001() {
let &(_, _, dp, psp, dm, psm) = S2_AIR
.iter()
.find(|&&(n, m, ..)| n == 2 && m == 2)
.expect("S2 air (2,2) present");
assert_eq!((dp, psp, dm, psm), (365.07, 292.85, 6.21, 292.80));
}
}