use crate::coordinates::spherical::position;
use crate::qtty::*;
use crate::time::JulianDate;
use std::fmt;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
type DegreePerYear = crate::qtty::Per<Degree, Year>;
type DegreesPerYear = crate::qtty::angular_rate::AngularRate<Degree, Year>;
const COS_DEC_EPSILON: f64 = 1.0e-12;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum RaProperMotionConvention {
MuAlpha,
MuAlphaStar,
}
impl std::fmt::Display for RaProperMotionConvention {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MuAlpha => write!(f, "\u{03bc}\u{03b1}"),
Self::MuAlphaStar => write!(f, "\u{03bc}\u{03b1}\u{2a}"),
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ProperMotion {
pub pm_ra: DegreesPerYear,
pub pm_dec: DegreesPerYear,
pub ra_convention: RaProperMotionConvention,
}
impl fmt::Display for ProperMotion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"\u{03bc}\u{03b1}{}: {}, \u{03bc}\u{03b4}: {}",
self.ra_convention, self.pm_ra, self.pm_dec
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ProperMotionError {
RightAscensionUndefinedAtPole { dec: Degrees },
}
impl fmt::Display for ProperMotionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ProperMotionError::RightAscensionUndefinedAtPole { dec } => write!(
f,
"right-ascension proper motion is undefined near the poles (dec={})",
dec
),
}
}
}
impl std::error::Error for ProperMotionError {}
impl RaProperMotionConvention {
fn to_mu_alpha(
self,
pm_ra: DegreesPerYear,
dec: Degrees,
) -> Result<DegreesPerYear, ProperMotionError> {
match self {
RaProperMotionConvention::MuAlpha => Ok(pm_ra),
RaProperMotionConvention::MuAlphaStar => {
let cos_dec = dec.to::<Radian>().cos();
if cos_dec.abs() <= COS_DEC_EPSILON {
return Err(ProperMotionError::RightAscensionUndefinedAtPole { dec });
}
Ok(pm_ra / cos_dec)
}
}
}
}
impl ProperMotion {
pub fn from_mu_alpha<T>(pm_ra: Quantity<T>, pm_dec: Quantity<T>) -> Self
where
T: AngularRateUnit,
{
Self {
pm_ra: pm_ra.to::<DegreePerYear>(),
pm_dec: pm_dec.to::<DegreePerYear>(),
ra_convention: RaProperMotionConvention::MuAlpha,
}
}
pub fn from_mu_alpha_star<T>(pm_ra_cosdec: Quantity<T>, pm_dec: Quantity<T>) -> Self
where
T: AngularRateUnit,
{
Self {
pm_ra: pm_ra_cosdec.to::<DegreePerYear>(),
pm_dec: pm_dec.to::<DegreePerYear>(),
ra_convention: RaProperMotionConvention::MuAlphaStar,
}
}
fn ra_rate_at_epoch(&self, dec: Degrees) -> Result<DegreesPerYear, ProperMotionError> {
self.ra_convention.to_mu_alpha(self.pm_ra, dec)
}
}
fn set_proper_motion_since_epoch<U: LengthUnit>(
mean_position: position::EquatorialMeanJ2000<U>,
proper_motion: ProperMotion,
jd: JulianDate,
epoch_jd: JulianDate,
) -> Result<position::EquatorialMeanJ2000<U>, ProperMotionError> {
let t: Years = Years::new((jd - epoch_jd) / Days::new(365.25));
let ra_rate = proper_motion.ra_rate_at_epoch(mean_position.dec())?;
Ok(position::EquatorialMeanJ2000::<U>::new(
mean_position.ra() + (ra_rate * t).to(),
mean_position.dec() + (proper_motion.pm_dec * t).to(),
mean_position.distance,
))
}
pub fn set_proper_motion_since_j2000<U: LengthUnit>(
mean_position: position::EquatorialMeanJ2000<U>,
proper_motion: ProperMotion,
jd: JulianDate,
) -> Result<position::EquatorialMeanJ2000<U>, ProperMotionError> {
set_proper_motion_since_epoch(mean_position, proper_motion, jd, JulianDate::J2000)
}
#[derive(Debug, Clone, Copy)]
pub struct StarSpaceMotion {
pub pm_ra_cos_dec: crate::qtty::angular_rate::AngularRate<MilliArcsecond, Year>,
pub pm_dec: crate::qtty::angular_rate::AngularRate<MilliArcsecond, Year>,
pub parallax: MilliArcseconds,
pub radial_velocity: crate::qtty::velocity::Velocity<Kilometer, unit::Second>,
}
pub fn propagate_space_motion(
mean_position: position::EquatorialMeanJ2000<AstronomicalUnit>,
motion: StarSpaceMotion,
jd: JulianDate,
epoch_jd: JulianDate,
) -> Result<position::EquatorialMeanJ2000<AstronomicalUnit>, ProperMotionError> {
let dec_deg = mean_position.dec();
let cos_dec = dec_deg.to::<Radian>().cos();
if cos_dec.abs() <= COS_DEC_EPSILON {
return Err(ProperMotionError::RightAscensionUndefinedAtPole { dec: dec_deg });
}
let alpha = mean_position.ra().to::<Radian>();
let delta = dec_deg.to::<Radian>();
let (sin_a, cos_a) = alpha.sin_cos();
let (sin_d, cos_d) = delta.sin_cos();
let pi_rad = motion.parallax.to::<Radian>();
if pi_rad <= qtty::Radian::zero() {
return Err(ProperMotionError::RightAscensionUndefinedAtPole { dec: dec_deg });
}
let r_au = 1.0 / pi_rad.value();
let p0 = [r_au * cos_d * cos_a, r_au * cos_d * sin_a, r_au * sin_d];
let e_alpha = [-sin_a, cos_a, 0.0];
let e_delta = [-sin_d * cos_a, -sin_d * sin_a, cos_d];
let pm_ra_rad_yr = motion.pm_ra_cos_dec.to::<Per<Radian, Year>>().value() / cos_dec;
let pm_dec_rad_yr = motion.pm_dec.to::<Per<Radian, Year>>().value();
let v_alpha_au_yr = pm_ra_rad_yr * r_au * cos_d;
let v_delta_au_yr = pm_dec_rad_yr * r_au;
let v_r_au_yr = motion
.radial_velocity
.to::<Per<AstronomicalUnit, Year>>()
.value();
let r_hat = [cos_d * cos_a, cos_d * sin_a, sin_d];
let pdot = [
e_alpha[0] * v_alpha_au_yr + e_delta[0] * v_delta_au_yr + r_hat[0] * v_r_au_yr,
e_alpha[1] * v_alpha_au_yr + e_delta[1] * v_delta_au_yr + r_hat[1] * v_r_au_yr,
e_alpha[2] * v_alpha_au_yr + e_delta[2] * v_delta_au_yr + r_hat[2] * v_r_au_yr,
];
let dt_yr = (jd - epoch_jd) / Days::new(365.25);
let p = [
p0[0] + dt_yr * pdot[0],
p0[1] + dt_yr * pdot[1],
p0[2] + dt_yr * pdot[2],
];
let r_new = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
if r_new <= 0.0 {
return Err(ProperMotionError::RightAscensionUndefinedAtPole { dec: dec_deg });
}
let new_dec = (p[2] / r_new).asin();
let new_ra = p[1].atan2(p[0]).rem_euclid(std::f64::consts::TAU);
Ok(position::EquatorialMeanJ2000::<AstronomicalUnit>::new(
Degrees::new(new_ra.to_degrees()),
Degrees::new(new_dec.to_degrees()),
r_new,
))
}
pub fn propagate_space_motion_since_j2000(
mean_position: position::EquatorialMeanJ2000<AstronomicalUnit>,
motion: StarSpaceMotion,
jd: JulianDate,
) -> Result<position::EquatorialMeanJ2000<AstronomicalUnit>, ProperMotionError> {
propagate_space_motion(mean_position, motion, jd, JulianDate::J2000)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::coordinates::{
centers::Geocentric, frames::EquatorialMeanJ2000, spherical::Position,
};
use crate::qtty::{AstronomicalUnit, Degrees};
use crate::time::JulianDate;
type DegreesPerYear = crate::qtty::Quantity<crate::qtty::Per<Degree, Year>>;
#[test]
fn test_proper_motion_linear_shift_mu_alpha() {
let mean_position = Position::<Geocentric, EquatorialMeanJ2000, AstronomicalUnit>::new(
Degrees::new(10.0), Degrees::new(20.0), 1.0, );
let mu = ProperMotion::from_mu_alpha::<DegreePerYear>(
DegreesPerYear::new(0.01),
DegreesPerYear::new(-0.005),
);
let jd_future = JulianDate::J2000 + 50.0 * JulianDate::JULIAN_YEAR;
let expected_ra = mean_position.ra() + Degrees::new(0.5);
let expected_dec = mean_position.dec() - Degrees::new(0.25);
let shifted = set_proper_motion_since_j2000(mean_position, mu, jd_future).unwrap();
let ra_err = (shifted.ra() - expected_ra).abs();
let dec_err = (shifted.dec() - expected_dec).abs();
assert!(
ra_err < Degrees::new(1e-6),
"RA shifted incorrectly: got {}, expected {}",
shifted.ra(),
expected_ra
);
assert!(
dec_err < Degrees::new(1e-6),
"DEC shifted incorrectly: got {}, expected {}",
shifted.dec(),
expected_dec
);
}
#[test]
fn test_proper_motion_mu_alpha_star_conversion() {
let mean_position = Position::<Geocentric, EquatorialMeanJ2000, AstronomicalUnit>::new(
Degrees::new(10.0), Degrees::new(60.0), 1.0,
);
let mu = ProperMotion::from_mu_alpha_star::<DegreePerYear>(
DegreesPerYear::new(0.005),
DegreesPerYear::new(0.0),
);
let jd_future = JulianDate::J2000 + 10.0 * JulianDate::JULIAN_YEAR;
let shifted = set_proper_motion_since_j2000(mean_position, mu, jd_future).unwrap();
let expected_ra = Degrees::new(10.1);
let ra_err = (shifted.ra() - expected_ra).abs();
assert!(
ra_err < Degrees::new(1e-6),
"RA shifted incorrectly for µα⋆: got {}, expected {}",
shifted.ra(),
expected_ra
);
}
#[test]
fn test_proper_motion_mu_alpha_star_rejects_pole() {
let mean_position = Position::<Geocentric, EquatorialMeanJ2000, AstronomicalUnit>::new(
Degrees::new(30.0),
Degrees::new(90.0),
1.0,
);
let mu = ProperMotion::from_mu_alpha_star::<DegreePerYear>(
DegreesPerYear::new(0.01),
DegreesPerYear::new(0.0),
);
let jd_future = JulianDate::J2000 + JulianDate::JULIAN_YEAR;
assert!(matches!(
set_proper_motion_since_j2000(mean_position, mu, jd_future),
Err(ProperMotionError::RightAscensionUndefinedAtPole { .. })
));
}
#[test]
fn space_motion_zero_state_is_static() {
let pos = Position::<Geocentric, EquatorialMeanJ2000, AstronomicalUnit>::new(
Degrees::new(45.0),
Degrees::new(30.0),
1.0,
);
let motion = StarSpaceMotion {
pm_ra_cos_dec: crate::qtty::angular_rate::AngularRate::new(0.0),
pm_dec: crate::qtty::angular_rate::AngularRate::new(0.0),
parallax: MilliArcseconds::new(100.0),
radial_velocity: crate::qtty::velocity::Velocity::new(0.0),
};
let jd_future = JulianDate::J2000 + 1000.0 * JulianDate::JULIAN_YEAR;
let p = propagate_space_motion_since_j2000(pos, motion, jd_future).unwrap();
assert!((p.ra().value() - 45.0).abs() < 1e-9);
assert!((p.dec().value() - 30.0).abs() < 1e-9);
}
#[test]
fn space_motion_matches_linear_pm_for_distant_star() {
let pos = Position::<Geocentric, EquatorialMeanJ2000, AstronomicalUnit>::new(
Degrees::new(120.0),
Degrees::new(0.0),
1.0,
);
let motion = StarSpaceMotion {
pm_ra_cos_dec: crate::qtty::angular_rate::AngularRate::new(100.0),
pm_dec: crate::qtty::angular_rate::AngularRate::new(0.0),
parallax: MilliArcseconds::new(100.0), radial_velocity: crate::qtty::velocity::Velocity::new(0.0),
};
let jd_future = JulianDate::J2000 + 100.0 * JulianDate::JULIAN_YEAR;
let space = propagate_space_motion_since_j2000(pos, motion, jd_future).unwrap();
let expected_ra = 120.0 + 0.002_777_8;
assert!(
(space.ra().value() - expected_ra).abs() < 0.001,
"RA after century = {}, expected ~{}",
space.ra().value(),
expected_ra
);
assert!(space.dec().value().abs() < 1e-3);
}
#[test]
fn space_motion_secular_acceleration_with_radial_velocity() {
let pos = Position::<Geocentric, EquatorialMeanJ2000, AstronomicalUnit>::new(
Degrees::new(269.45),
Degrees::new(4.66),
1.0,
);
let motion = StarSpaceMotion {
pm_ra_cos_dec: crate::qtty::angular_rate::AngularRate::new(0.0),
pm_dec: crate::qtty::angular_rate::AngularRate::new(10_000.0),
parallax: MilliArcseconds::new(547.0),
radial_velocity: crate::qtty::velocity::Velocity::new(-110.0),
};
let p10 = propagate_space_motion_since_j2000(
pos,
motion,
JulianDate::J2000 + 10.0 * JulianDate::JULIAN_YEAR,
)
.unwrap();
let p20 = propagate_space_motion_since_j2000(
pos,
motion,
JulianDate::J2000 + 20.0 * JulianDate::JULIAN_YEAR,
)
.unwrap();
let d10 = (p10.dec() - pos.dec()).value().abs();
let d20_minus_d10 = (p20.dec() - p10.dec()).value().abs();
assert!(
d20_minus_d10 > d10,
"expected accelerating PM (d20-d10 = {} > d10 = {})",
d20_minus_d10,
d10
);
}
#[test]
fn space_motion_negative_parallax_errors() {
let pos = Position::<Geocentric, EquatorialMeanJ2000, AstronomicalUnit>::new(
Degrees::new(0.0),
Degrees::new(0.0),
1.0,
);
let motion = StarSpaceMotion {
pm_ra_cos_dec: crate::qtty::angular_rate::AngularRate::new(0.0),
pm_dec: crate::qtty::angular_rate::AngularRate::new(0.0),
parallax: MilliArcseconds::new(-1.0),
radial_velocity: crate::qtty::velocity::Velocity::new(0.0),
};
let result = propagate_space_motion_since_j2000(
pos,
motion,
JulianDate::J2000 + JulianDate::JULIAN_YEAR,
);
assert!(matches!(
result,
Err(ProperMotionError::RightAscensionUndefinedAtPole { .. })
));
}
}