use nalgebra::Vector3;
use crate::constants::C_AUDAY;
use crate::framelib::ECLIPJ2000_MATRIX_INV;
use crate::planetlib::{Body, Ephemeris};
use crate::time::Time;
use crate::{Result, StarfieldError};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ApparentPosition {
pub ra_deg: f64,
pub dec_deg: f64,
pub delta_au: f64,
}
pub fn apparent_equatorial(
target_helio_ecliptic_au: &Vector3<f64>,
earth_helio_ecliptic_au: &Vector3<f64>,
) -> ApparentPosition {
let geocentric_ecliptic = target_helio_ecliptic_au - earth_helio_ecliptic_au;
let geocentric_equatorial = *ECLIPJ2000_MATRIX_INV * geocentric_ecliptic;
let delta_au = geocentric_equatorial.norm();
let ra_deg = geocentric_equatorial
.y
.atan2(geocentric_equatorial.x)
.to_degrees()
.rem_euclid(360.0);
let dec_deg = (geocentric_equatorial.z / delta_au).asin().to_degrees();
ApparentPosition {
ra_deg,
dec_deg,
delta_au,
}
}
pub fn apparent_equatorial_light_time(
target_at: impl Fn(f64) -> Vector3<f64>,
earth_helio_ecliptic_au: &Vector3<f64>,
) -> ApparentPosition {
let mut dt_days = 0.0;
let mut position = apparent_equatorial(&target_at(dt_days), earth_helio_ecliptic_au);
for _ in 0..10 {
let next_dt = -position.delta_au / C_AUDAY;
if (next_dt - dt_days).abs() < 1e-9 {
break;
}
dt_days = next_dt;
position = apparent_equatorial(&target_at(dt_days), earth_helio_ecliptic_au);
}
position
}
pub fn earth_positions_from_ephemeris(
ephemeris: &mut Ephemeris,
times: &[Time],
) -> Result<Vec<Vector3<f64>>> {
times
.iter()
.map(|t| {
ephemeris
.ecliptic_state(Body::Earth, t)
.map(|state| state.position.to_vector3())
.map_err(|e| StarfieldError::CalculationError(format!("Earth ephemeris: {e}")))
})
.collect()
}
pub fn earth_positions_with(
mut earth_at: impl FnMut(&Time) -> Vector3<f64>,
times: &[Time],
) -> Vec<Vector3<f64>> {
times.iter().map(&mut earth_at).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constants::GM_SUN;
use crate::jplephem::SpiceKernel;
use crate::keplerlib::KeplerOrbit;
use crate::time::Timescale;
fn ecliptic_orbit(a: f64, e: f64, i_deg: f64, m_deg: f64, epoch: &Time) -> KeplerOrbit {
KeplerOrbit::from_mean_anomaly(
a * (1.0 - e * e),
e,
i_deg,
0.0,
0.0,
m_deg,
epoch,
GM_SUN,
Some(10),
None,
)
}
#[test]
fn ecliptic_pole_maps_to_obliquity_complement() {
let pos = apparent_equatorial(&Vector3::new(0.0, 0.0, 1.0), &Vector3::zeros());
assert!(
(pos.dec_deg - (90.0 - 23.439_291)).abs() < 1e-4,
"dec = {}",
pos.dec_deg
);
}
#[test]
fn equinox_direction_unchanged() {
let pos = apparent_equatorial(&Vector3::new(1.0, 0.0, 0.0), &Vector3::zeros());
assert!(pos.ra_deg.abs() < 1e-9 || (pos.ra_deg - 360.0).abs() < 1e-9);
assert!(pos.dec_deg.abs() < 1e-9);
}
#[test]
fn ecliptic_plane_orbit_declination_swings_by_obliquity() {
let ts = Timescale::default();
let epoch = ts.tt_jd(2_451_545.0, None);
let mut min_dec = f64::INFINITY;
let mut max_dec = f64::NEG_INFINITY;
for k in 0..72 {
let orbit = ecliptic_orbit(400.0, 1e-6, 0.0, k as f64 * 5.0, &epoch);
let helio = orbit.at(&epoch).position;
let pos = apparent_equatorial(&helio, &Vector3::zeros());
min_dec = min_dec.min(pos.dec_deg);
max_dec = max_dec.max(pos.dec_deg);
}
assert!(min_dec < -20.0, "min dec = {min_dec:.1}");
assert!(max_dec > 20.0, "max dec = {max_dec:.1}");
assert!(max_dec < 23.5 && min_dec > -23.5);
}
#[test]
fn parallax_amplitude_scales_inversely_with_distance() {
let target = Vector3::new(0.0, 400.0, 0.0);
let mut ra_min = f64::INFINITY;
let mut ra_max = f64::NEG_INFINITY;
for k in 0..=24 {
let theta = std::f64::consts::TAU * k as f64 / 24.0;
let earth = Vector3::new(theta.cos(), theta.sin(), 0.0);
let pos = apparent_equatorial(&target, &earth);
ra_min = ra_min.min(pos.ra_deg);
ra_max = ra_max.max(pos.ra_deg);
}
let half_amplitude = (ra_max - ra_min) / 2.0;
let expected = (1.0_f64 / 400.0).asin().to_degrees();
assert!(
(half_amplitude - expected).abs() < 0.03,
"half-amplitude = {half_amplitude:.4} deg, expected ~{expected:.4}"
);
}
#[test]
fn light_time_displaces_by_sky_motion_over_delta_over_c() {
let r = 400.0;
let v = Vector3::new(0.001, 0.0, 0.0); let target_at = |dt: f64| Vector3::new(v.x * dt, r, 0.0);
let earth = Vector3::zeros();
let instantaneous = apparent_equatorial(&target_at(0.0), &earth);
let apparent = apparent_equatorial_light_time(target_at, &earth);
let lt_days = r / C_AUDAY;
assert!((2.0..2.5).contains(<_days), "lt = {lt_days:.2} d");
let lambda_shift = (v.x * lt_days / r).to_degrees();
let expected_shift = -lambda_shift / 23.439_291_f64.to_radians().cos();
let actual_shift = instantaneous.ra_deg - apparent.ra_deg;
assert!(
(actual_shift - expected_shift).abs() < 2e-6,
"shift = {actual_shift:.2e} deg, expected {expected_shift:.2e}"
);
}
#[test]
fn ephemeris_earth_positions_are_one_au_heliocentric() {
let Ok(kernel) = SpiceKernel::open("test_data/de421.bsp") else {
eprintln!("skipping: test_data/de421.bsp not available");
return;
};
let mut ephemeris = Ephemeris::from_kernel(kernel);
let ts = Timescale::default();
let times: Vec<Time> = (0..12)
.map(|k| ts.tt_jd(2_456_000.0 + 30.0 * k as f64, None))
.collect();
let positions = earth_positions_from_ephemeris(&mut ephemeris, ×).unwrap();
assert_eq!(positions.len(), times.len());
for p in &positions {
let r = p.norm();
assert!((0.97..1.03).contains(&r), "|Earth| = {r:.4} AU");
assert!((p.z / r).asin().to_degrees().abs() < 0.01);
}
let spread = positions
.iter()
.map(|p| p.x)
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| {
(lo.min(x), hi.max(x))
});
assert!(spread.1 - spread.0 > 1.0);
}
#[test]
fn closure_earth_positions_align_with_times() {
let ts = Timescale::default();
let times: Vec<Time> = (0..4)
.map(|k| ts.tt_jd(2_451_545.0 + k as f64, None))
.collect();
let positions = earth_positions_with(
|t| {
let theta = std::f64::consts::TAU * (t.tt() - 2_451_545.0) / 365.25;
Vector3::new(theta.cos(), theta.sin(), 0.0)
},
×,
);
assert_eq!(positions.len(), 4);
assert!((positions[0] - Vector3::new(1.0, 0.0, 0.0)).norm() < 1e-12);
assert!(positions[1].y > 0.0);
}
}