use crate::timescales::JD_J2000;
const ARCSEC_TO_RAD: f64 = std::f64::consts::PI / (180.0 * 3600.0);
const DAYS_PER_CENTURY: f64 = 36_525.0;
pub type Mat3 = [[f64; 3]; 3];
pub type Vec3 = [f64; 3];
#[derive(Clone, Copy, Debug)]
pub struct FwAngles {
pub gamma_bar: f64,
pub phi_bar: f64,
pub psi_bar: f64,
pub eps_a: f64,
}
pub fn julian_centuries_tt(jd_tt: f64) -> f64 {
(jd_tt - JD_J2000) / DAYS_PER_CENTURY
}
fn poly(t: f64, c: &[f64]) -> f64 {
c.iter().rev().fold(0.0, |acc, &ci| acc * t + ci)
}
pub fn fw_angles(jd_tt: f64) -> FwAngles {
let t = julian_centuries_tt(jd_tt);
let gamb = poly(
t,
&[
-0.052928,
10.556378,
0.4932044,
-0.00031238,
-0.000002788,
0.000000026,
],
);
let phib = poly(
t,
&[
84381.412819,
-46.811016,
0.0511268,
0.00053289,
-0.00000044,
-0.0000000176,
],
);
let psib = poly(
t,
&[
-0.041775,
5038.481484,
1.5584175,
-0.00018522,
-0.000026452,
-0.0000000148,
],
);
let epsa = poly(
t,
&[
84381.406,
-46.836769,
-0.0001831,
0.0020034,
-0.000000576,
-0.0000000434,
],
);
FwAngles {
gamma_bar: gamb * ARCSEC_TO_RAD,
phi_bar: phib * ARCSEC_TO_RAD,
psi_bar: psib * ARCSEC_TO_RAD,
eps_a: epsa * ARCSEC_TO_RAD,
}
}
pub(crate) fn rx(phi: f64) -> Mat3 {
let (s, c) = phi.sin_cos();
[[1.0, 0.0, 0.0], [0.0, c, s], [0.0, -s, c]]
}
pub(crate) fn ry(theta: f64) -> Mat3 {
let (s, c) = theta.sin_cos();
[[c, 0.0, -s], [0.0, 1.0, 0.0], [s, 0.0, c]]
}
pub(crate) fn rz(psi: f64) -> Mat3 {
let (s, c) = psi.sin_cos();
[[c, s, 0.0], [-s, c, 0.0], [0.0, 0.0, 1.0]]
}
pub(crate) fn matmul(a: &Mat3, b: &Mat3) -> Mat3 {
let mut r = [[0.0; 3]; 3];
for (i, ri) in r.iter_mut().enumerate() {
for (j, rij) in ri.iter_mut().enumerate() {
*rij = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
}
}
r
}
pub fn mat_vec(m: &Mat3, v: Vec3) -> Vec3 {
[
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
]
}
pub fn transpose(m: &Mat3) -> Mat3 {
[
[m[0][0], m[1][0], m[2][0]],
[m[0][1], m[1][1], m[2][1]],
[m[0][2], m[1][2], m[2][2]],
]
}
pub fn fw_matrix(a: FwAngles) -> Mat3 {
let mut m = rz(a.gamma_bar);
m = matmul(&rx(a.phi_bar), &m);
m = matmul(&rz(-a.psi_bar), &m);
matmul(&rx(-a.eps_a), &m)
}
pub fn precession_matrix(jd_tt: f64) -> Mat3 {
fw_matrix(fw_angles(jd_tt))
}
pub fn gcrs_to_mod(v: Vec3, jd_tt: f64) -> Vec3 {
mat_vec(&precession_matrix(jd_tt), v)
}
pub fn mod_to_gcrs(v: Vec3, jd_tt: f64) -> Vec3 {
mat_vec(&transpose(&precession_matrix(jd_tt)), v)
}
#[cfg(test)]
mod tests {
use super::*;
const ARCSEC: f64 = ARCSEC_TO_RAD;
#[test]
fn mean_obliquity_at_j2000_is_iau2006_value() {
let a = fw_angles(JD_J2000);
let deg = a.eps_a / ARCSEC / 3600.0;
assert!((deg - 23.43927944).abs() < 1e-7, "ε̄(J2000) = {deg}°");
}
#[test]
fn fw_constant_terms_match_definition_at_j2000() {
let a = fw_angles(JD_J2000);
assert!((a.gamma_bar / ARCSEC - (-0.052_928)).abs() < 1e-9);
assert!((a.phi_bar / ARCSEC - 84_381.412_819).abs() < 1e-6);
assert!((a.psi_bar / ARCSEC - (-0.041_775)).abs() < 1e-9);
}
#[test]
fn psi_bar_accumulates_general_precession() {
let a = fw_angles(JD_J2000 + DAYS_PER_CENTURY);
let psi_arcsec = a.psi_bar / ARCSEC;
assert!(
(psi_arcsec - 5_039.997_915).abs() < 1e-4,
"ψ̄(1cy) = {psi_arcsec}″"
);
}
#[test]
fn precession_matrix_is_orthonormal() {
let m = precession_matrix(JD_J2000 + 2.5 * DAYS_PER_CENTURY);
let mt = transpose(&m);
let p = matmul(&m, &mt);
for (i, row) in p.iter().enumerate() {
for (j, &pij) in row.iter().enumerate() {
let expect = if i == j { 1.0 } else { 0.0 };
assert!((pij - expect).abs() < 1e-12, "R·Rᵀ[{i}][{j}] = {pij}");
}
}
let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
assert!((det - 1.0).abs() < 1e-12, "det = {det}");
}
#[test]
fn matrix_at_j2000_is_near_identity_frame_bias_only() {
let m = precession_matrix(JD_J2000);
for (i, row) in m.iter().enumerate() {
for (j, &mij) in row.iter().enumerate() {
let expect = if i == j { 1.0 } else { 0.0 };
assert!((mij - expect).abs() < 1e-6, "R(J2000)[{i}][{j}] = {mij}");
}
}
}
#[test]
fn one_century_rotation_angle_is_general_precession() {
let m = precession_matrix(JD_J2000 + DAYS_PER_CENTURY);
let trace = m[0][0] + m[1][1] + m[2][2];
let theta_deg = (((trace - 1.0) / 2.0).clamp(-1.0, 1.0)).acos().to_degrees();
assert!(
(1.2..1.6).contains(&theta_deg),
"1-century precession θ = {theta_deg}°"
);
}
#[test]
fn gcrs_mod_round_trips() {
let v = [7000.0e3, -1200.0e3, 4200.0e3];
let jd = JD_J2000 + 0.37 * DAYS_PER_CENTURY;
let back = mod_to_gcrs(gcrs_to_mod(v, jd), jd);
for k in 0..3 {
assert!(
(back[k] - v[k]).abs() < 1e-6,
"round-trip[{k}] = {}",
back[k]
);
}
let moved = gcrs_to_mod(v, jd);
let shift =
((moved[0] - v[0]).powi(2) + (moved[1] - v[1]).powi(2) + (moved[2] - v[2]).powi(2))
.sqrt();
assert!(shift > 1.0, "precession should move the vector: {shift} m");
}
}