use std::{f64::consts::TAU, fmt};
use crate::{
Degrees, Radians,
coordinates::{
NORM_MIN,
cartesian::{CartesianCoord, CartesianCoordCov},
cov2::Cov2,
},
};
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct EquCoord {
pub ra: Radians,
pub ra_error: Radians,
pub dec: Radians,
pub dec_error: Radians,
}
impl EquCoord {
#[inline]
pub fn new(ra: Radians, ra_error: Radians, dec: Radians, dec_error: Radians) -> Self {
Self {
ra,
ra_error,
dec,
dec_error,
}
}
#[inline]
pub fn from_degrees(
ra_deg: Degrees,
ra_error_deg: Degrees,
dec_deg: Degrees,
dec_error_deg: Degrees,
) -> Self {
Self {
ra: ra_deg.to_radians(),
ra_error: ra_error_deg.to_radians(),
dec: dec_deg.to_radians(),
dec_error: dec_error_deg.to_radians(),
}
}
#[inline]
pub fn to_degrees(&self) -> (Degrees, Degrees) {
(self.ra.to_degrees(), self.dec.to_degrees())
}
#[inline]
pub fn error_in_degrees(&self) -> (Degrees, Degrees) {
(self.ra_error.to_degrees(), self.dec_error.to_degrees())
}
#[inline]
pub fn angular_separation(&self, other: &EquCoord) -> Radians {
let dlon = other.ra - self.ra;
let (slon, clon) = dlon.sin_cos();
let (slat1, clat1) = self.dec.sin_cos();
let (slat2, clat2) = other.dec.sin_cos();
let num1 = clat2 * slon;
let num2 = clat1 * slat2 - slat1 * clat2 * clon;
let denom = slat1 * slat2 + clat1 * clat2 * clon;
num1.hypot(num2).atan2(denom)
}
#[inline]
pub fn spherical_midpoint(&self, other: &EquCoord) -> Self {
let cart1: CartesianCoord = (*self).into();
let cart2: CartesianCoord = (*other).into();
let cart = cart1 + cart2;
let r = (cart.x * cart.x + cart.y * cart.y + cart.z * cart.z)
.sqrt()
.max(NORM_MIN);
CartesianCoord {
x: cart.x / r,
y: cart.y / r,
z: cart.z / r,
}
.into()
}
}
impl fmt::Display for EquCoord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let ra_deg = self.ra.to_degrees();
let dec_deg = self.dec.to_degrees();
let ra_err_arcsec = self.ra_error.to_degrees() * 3600.0;
let dec_err_arcsec = self.dec_error.to_degrees() * 3600.0;
let ra_sex = deg_to_sexagesimal(ra_deg, true);
let dec_sex = deg_to_sexagesimal(dec_deg, false);
write!(
f,
"RA: {ra_sex} ({ra_deg:.6} deg) ± {ra_err_arcsec:.3} arcsec, \
Dec: {dec_sex} ({dec_deg:.6} deg) ± {dec_err_arcsec:.3} arcsec"
)
}
}
fn deg_to_sexagesimal(deg: f64, is_ra: bool) -> String {
if is_ra {
let total_hours = deg / 15.0;
let h = total_hours.abs().floor() as u32;
let rem_m = (total_hours.abs() - h as f64) * 60.0;
let m = rem_m.floor() as u32;
let s = (rem_m - m as f64) * 60.0;
format!("{:02}h {:02}m {:06.3}s", h, m, s)
} else {
let sign = if deg < 0.0 { '-' } else { '+' };
let abs = deg.abs();
let d = abs.floor() as u32;
let rem_m = (abs - d as f64) * 60.0;
let m = rem_m.floor() as u32;
let s = (rem_m - m as f64) * 60.0;
format!("{}{:02}d {:02}m {:06.3}s", sign, d, m, s)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct EquCoordCov {
pub coord: EquCoord,
pub cov: Cov2,
}
impl EquCoordCov {
#[inline]
pub fn new(coord: EquCoord, cov: Cov2) -> Self {
Self { coord, cov }
}
#[inline]
pub fn from_equ(c: EquCoord) -> Self {
let cov = Cov2::from_equ(&c);
Self { coord: c, cov }
}
pub fn to_cartesian_cov(&self) -> CartesianCoordCov {
let (sdec, cdec) = self.coord.dec.sin_cos();
let (sra, cra) = self.coord.ra.sin_cos();
let j = [
[-cdec * sra, -sdec * cra],
[cdec * cra, -sdec * sra],
[0.0_f64, cdec],
];
let cov3 = self.cov.transform_j3(j);
CartesianCoordCov {
coord: CartesianCoord::from(&self.coord),
cov: cov3,
}
}
}
impl From<EquCoord> for EquCoordCov {
#[inline]
fn from(c: EquCoord) -> Self {
EquCoordCov::from_equ(c)
}
}
impl From<&CartesianCoord> for EquCoord {
fn from(cart: &CartesianCoord) -> Self {
let CartesianCoord { x, y, z } = *cart;
let rho = x.hypot(y);
let dec = z.atan2(rho);
let ra = y.atan2(x).rem_euclid(TAU);
EquCoord::new(ra, 0.0, dec, 0.0)
}
}
impl From<CartesianCoord> for EquCoord {
#[inline]
fn from(cart: CartesianCoord) -> Self {
Self::from(&cart)
}
}
#[cfg(test)]
mod equ_coord_tests {
use super::*;
use approx::assert_abs_diff_eq;
use proptest::prelude::*;
use std::f64::consts::PI;
prop_compose! {
fn valid_ra()(ra in 0.0_f64..=(2.0 * PI)) -> f64 { ra }
}
prop_compose! {
fn valid_dec()(dec in (-PI / 2.0)..=(PI / 2.0)) -> f64 { dec }
}
prop_compose! {
fn valid_coord()(ra in valid_ra(), dec in valid_dec()) -> EquCoord {
EquCoord::new(ra, 0.0, dec, 0.0)
}
}
mod constructor {
use super::*;
#[test]
fn new_stores_ra_and_dec_fields() {
let ra = 1.2345_f64;
let dec = -0.6789_f64;
let coord = EquCoord::new(ra, 0.0, dec, 0.0);
assert_abs_diff_eq!(coord.ra, ra, epsilon = 0.0);
assert_abs_diff_eq!(coord.dec, dec, epsilon = 0.0);
}
#[test]
fn new_with_zero_values() {
let coord = EquCoord::new(0.0, 0.0, 0.0, 0.0);
assert_abs_diff_eq!(coord.ra, 0.0, epsilon = 0.0);
assert_abs_diff_eq!(coord.dec, 0.0, epsilon = 0.0);
}
#[test]
fn new_stores_error_fields() {
let ra_err = 0.001_f64;
let dec_err = 0.002_f64;
let coord = EquCoord::new(0.0, ra_err, 0.0, dec_err);
assert_abs_diff_eq!(coord.ra_error, ra_err, epsilon = 0.0);
assert_abs_diff_eq!(coord.dec_error, dec_err, epsilon = 0.0);
}
#[test]
fn from_degrees_converts_ra_correctly() {
let coord = EquCoord::from_degrees(180.0, 0.0, 0.0, 0.0);
assert_abs_diff_eq!(coord.ra, PI, epsilon = 1e-15);
}
#[test]
fn from_degrees_converts_dec_correctly() {
let coord = EquCoord::from_degrees(0.0, 0.0, 90.0, 0.0);
assert_abs_diff_eq!(coord.dec, PI / 2.0, epsilon = 1e-15);
}
#[test]
fn from_degrees_converts_errors_correctly() {
let coord = EquCoord::from_degrees(0.0, 90.0, 0.0, 45.0);
assert_abs_diff_eq!(coord.ra_error, PI / 2.0, epsilon = 1e-15);
assert_abs_diff_eq!(coord.dec_error, PI / 4.0, epsilon = 1e-15);
}
#[test]
fn from_degrees_handles_negative_dec() {
let coord = EquCoord::from_degrees(0.0, 0.0, -45.0, 0.0);
assert_abs_diff_eq!(coord.dec, -PI / 4.0, epsilon = 1e-15);
}
#[test]
fn from_degrees_handles_full_circle_ra() {
let coord = EquCoord::from_degrees(360.0, 0.0, 0.0, 0.0);
assert_abs_diff_eq!(coord.ra, 2.0 * PI, epsilon = 1e-15);
}
}
mod degree_conversion {
use super::*;
#[test]
fn to_degrees_round_trips_ra() {
let ra_deg = 123.456_f64;
let coord = EquCoord::from_degrees(ra_deg, 0.0, 0.0, 0.0);
let (ra_out, _) = coord.to_degrees();
assert_abs_diff_eq!(ra_out, ra_deg, epsilon = 1e-13);
}
#[test]
fn to_degrees_round_trips_dec() {
let dec_deg = -33.7_f64;
let coord = EquCoord::from_degrees(0.0, 0.0, dec_deg, 0.0);
let (_, dec_out) = coord.to_degrees();
assert_abs_diff_eq!(dec_out, dec_deg, epsilon = 1e-13);
}
#[test]
fn to_degrees_returns_zero_for_origin() {
let coord = EquCoord::new(0.0, 0.0, 0.0, 0.0);
let (ra_deg, dec_deg) = coord.to_degrees();
assert_abs_diff_eq!(ra_deg, 0.0, epsilon = 0.0);
assert_abs_diff_eq!(dec_deg, 0.0, epsilon = 0.0);
}
#[test]
fn to_degrees_converts_pi_to_180() {
let coord = EquCoord::new(PI, 0.0, 0.0, 0.0);
let (ra_deg, _) = coord.to_degrees();
assert_abs_diff_eq!(ra_deg, 180.0, epsilon = 1e-13);
}
}
mod display {
use super::*;
#[test]
fn display_contains_ra_label() {
let coord = EquCoord::from_degrees(90.0, 0.0, 45.0, 0.0);
let output = format!("{coord}");
assert!(
output.contains("RA:"),
"Display output missing 'RA:' label: {output}"
);
}
#[test]
fn display_contains_dec_label() {
let coord = EquCoord::from_degrees(90.0, 0.0, 45.0, 0.0);
let output = format!("{coord}");
assert!(
output.contains("Dec:"),
"Display output missing 'Dec:' label: {output}"
);
}
#[test]
fn display_contains_deg_unit() {
let coord = EquCoord::from_degrees(10.0, 0.0, -20.0, 0.0);
let output = format!("{coord}");
assert!(
output.contains("deg"),
"Display output missing 'deg' unit: {output}"
);
}
#[test]
fn display_contains_numeric_ra_value() {
let coord = EquCoord::from_degrees(180.0, 0.0, 0.0, 0.0);
let output = format!("{coord}");
assert!(
output.contains("180"),
"Display output missing RA value '180': {output}"
);
}
#[test]
fn display_contains_numeric_dec_value() {
let coord = EquCoord::from_degrees(0.0, 0.0, -90.0, 0.0);
let output = format!("{coord}");
assert!(
output.contains("-90"),
"Display output missing Dec value '-90': {output}"
);
}
}
mod angular_separation {
use super::*;
#[test]
fn separation_same_point_is_zero() {
let c = EquCoord::from_degrees(120.0, 0.0, 45.0, 0.0);
assert_abs_diff_eq!(c.angular_separation(&c), 0.0, epsilon = 1e-12);
}
#[test]
fn separation_antipodal_points_is_pi() {
let c1 = EquCoord::from_degrees(0.0, 0.0, 30.0, 0.0);
let c2 = EquCoord::from_degrees(180.0, 0.0, -30.0, 0.0);
assert_abs_diff_eq!(c1.angular_separation(&c2), PI, epsilon = 1e-12);
}
#[test]
fn separation_equator_90deg() {
let c1 = EquCoord::from_degrees(0.0, 0.0, 0.0, 0.0);
let c2 = EquCoord::from_degrees(90.0, 0.0, 0.0, 0.0);
assert_abs_diff_eq!(c1.angular_separation(&c2), PI / 2.0, epsilon = 1e-12);
}
#[test]
fn separation_poles_is_pi() {
let north = EquCoord::from_degrees(0.0, 0.0, 90.0, 0.0);
let south = EquCoord::from_degrees(0.0, 0.0, -90.0, 0.0);
assert_abs_diff_eq!(north.angular_separation(&south), PI, epsilon = 1e-12);
}
#[test]
fn separation_sirius_canopus_known_value() {
let sirius = EquCoord::from_degrees(101.2875, 0.0, -16.7161, 0.0);
let canopus = EquCoord::from_degrees(95.9879, 0.0, -52.6956, 0.0);
let expected_deg = 36.2208_f64;
let sep_deg = sirius.angular_separation(&canopus).to_degrees();
assert_abs_diff_eq!(sep_deg, expected_deg, epsilon = 0.01);
}
#[test]
fn separation_same_meridian_equals_dec_difference() {
let c1 = EquCoord::from_degrees(45.0, 0.0, 10.0, 0.0);
let c2 = EquCoord::from_degrees(45.0, 0.0, 70.0, 0.0);
let expected = (70.0_f64 - 10.0_f64).to_radians();
assert_abs_diff_eq!(c1.angular_separation(&c2), expected, epsilon = 1e-12);
}
#[test]
fn separation_north_pole_with_itself_is_zero() {
let pole = EquCoord::from_degrees(0.0, 0.0, 90.0, 0.0);
assert_abs_diff_eq!(pole.angular_separation(&pole), 0.0, epsilon = 1e-12);
}
}
proptest! {
#[test]
fn prop_separation_in_range(a in valid_coord(), b in valid_coord()) {
let sep = a.angular_separation(&b);
prop_assert!(sep >= 0.0, "negative separation: {sep}");
prop_assert!(sep <= PI + 1e-12, "separation exceeds π: {sep}");
}
#[test]
fn prop_separation_is_symmetric(a in valid_coord(), b in valid_coord()) {
let sep_ab = a.angular_separation(&b);
let sep_ba = b.angular_separation(&a);
prop_assert!(
(sep_ab - sep_ba).abs() < 1e-12,
"asymmetry detected: {sep_ab} vs {sep_ba}"
);
}
#[test]
fn prop_separation_with_self_is_zero(a in valid_coord()) {
let sep = a.angular_separation(&a);
prop_assert!(
sep.abs() < 1e-12,
"self-separation is non-zero: {sep}"
);
}
#[test]
fn prop_separation_invariant_under_ra_shift(
a in valid_coord(),
b in valid_coord(),
shift in 0.0_f64..=(2.0 * PI),
) {
let a_shifted = EquCoord::new((a.ra + shift) % (2.0 * PI), 0.0, a.dec, 0.0);
let b_shifted = EquCoord::new((b.ra + shift) % (2.0 * PI), 0.0, b.dec, 0.0);
let sep_orig = a.angular_separation(&b);
let sep_shifted = a_shifted.angular_separation(&b_shifted);
prop_assert!(
(sep_orig - sep_shifted).abs() < 1e-10,
"separation changed after RA shift={shift}: {sep_orig} vs {sep_shifted}"
);
}
}
mod error_in_degrees {
use super::*;
#[test]
fn ra_error_converted_to_degrees() {
let coord = EquCoord::new(0.0, PI / 2.0, 0.0, 0.0);
let (ra_err_deg, _) = coord.error_in_degrees();
assert_abs_diff_eq!(ra_err_deg, 90.0, epsilon = 1e-13);
}
#[test]
fn dec_error_converted_to_degrees() {
let coord = EquCoord::new(0.0, 0.0, 0.0, PI / 4.0);
let (_, dec_err_deg) = coord.error_in_degrees();
assert_abs_diff_eq!(dec_err_deg, 45.0, epsilon = 1e-13);
}
#[test]
fn zero_errors_remain_zero() {
let coord = EquCoord::new(1.0, 0.0, 0.5, 0.0);
let (ra_err_deg, dec_err_deg) = coord.error_in_degrees();
assert_abs_diff_eq!(ra_err_deg, 0.0, epsilon = 0.0);
assert_abs_diff_eq!(dec_err_deg, 0.0, epsilon = 0.0);
}
#[test]
fn error_in_degrees_round_trips_from_degrees() {
let ra_err_deg = 0.5_f64;
let dec_err_deg = 1.2_f64;
let coord = EquCoord::from_degrees(0.0, ra_err_deg, 0.0, dec_err_deg);
let (ra_out, dec_out) = coord.error_in_degrees();
assert_abs_diff_eq!(ra_out, ra_err_deg, epsilon = 1e-13);
assert_abs_diff_eq!(dec_out, dec_err_deg, epsilon = 1e-13);
}
#[test]
fn error_in_degrees_independent_of_position() {
let err_rad = 0.01_f64;
let c1 = EquCoord::new(0.0, err_rad, 0.0, err_rad);
let c2 = EquCoord::new(PI / 3.0, err_rad, PI / 6.0, err_rad);
let (ra1, dec1) = c1.error_in_degrees();
let (ra2, dec2) = c2.error_in_degrees();
assert_abs_diff_eq!(ra1, ra2, epsilon = 1e-15);
assert_abs_diff_eq!(dec1, dec2, epsilon = 1e-15);
}
}
mod spherical_midpoint {
use super::*;
#[test]
fn midpoint_with_self_is_same_point() {
let c = EquCoord::from_degrees(200.0, 0.0, -33.0, 0.0);
let mid = c.spherical_midpoint(&c);
assert_abs_diff_eq!(mid.ra, c.ra, epsilon = 1e-12);
assert_abs_diff_eq!(mid.dec, c.dec, epsilon = 1e-12);
}
#[test]
fn equatorial_midpoint_has_averaged_ra_and_zero_dec() {
let c1 = EquCoord::from_degrees(20.0, 0.0, 0.0, 0.0);
let c2 = EquCoord::from_degrees(100.0, 0.0, 0.0, 0.0);
let mid = c1.spherical_midpoint(&c2);
assert_abs_diff_eq!(mid.dec, 0.0, epsilon = 1e-12);
assert_abs_diff_eq!(mid.ra, 60.0_f64.to_radians(), epsilon = 1e-12);
}
#[test]
fn midpoint_is_symmetric() {
let a = EquCoord::from_degrees(50.0, 0.0, 10.0, 0.0);
let b = EquCoord::from_degrees(110.0, 0.0, -30.0, 0.0);
let mid_ab = a.spherical_midpoint(&b);
let mid_ba = b.spherical_midpoint(&a);
assert_abs_diff_eq!(mid_ab.ra, mid_ba.ra, epsilon = 1e-12);
assert_abs_diff_eq!(mid_ab.dec, mid_ba.dec, epsilon = 1e-12);
}
#[test]
fn midpoint_of_antipodal_poles_is_not_nan() {
let north = EquCoord::from_degrees(0.0, 0.0, 90.0, 0.0);
let south = EquCoord::from_degrees(0.0, 0.0, -90.0, 0.0);
let mid = north.spherical_midpoint(&south);
assert!(!mid.ra.is_nan(), "midpoint RA is NaN for antipodal poles");
assert!(!mid.dec.is_nan(), "midpoint Dec is NaN for antipodal poles");
}
#[test]
fn equatorial_midpoint_90_degrees_apart() {
let c1 = EquCoord::from_degrees(0.0, 0.0, 0.0, 0.0);
let c2 = EquCoord::from_degrees(90.0, 0.0, 0.0, 0.0);
let mid = c1.spherical_midpoint(&c2);
assert_abs_diff_eq!(mid.dec, 0.0, epsilon = 1e-12);
assert_abs_diff_eq!(mid.ra, 45.0_f64.to_radians(), epsilon = 1e-12);
}
}
mod equ_coord_cov {
use super::*;
use crate::coordinates::cov2::Cov2;
const EPS: f64 = 1e-12;
#[test]
fn from_equ_diagonal() {
let c = EquCoord::new(0.1, 0.01, 0.2, 0.02);
let ec = EquCoordCov::from_equ(c);
assert_abs_diff_eq!(ec.cov.xx, 0.01 * 0.01, epsilon = EPS);
assert_abs_diff_eq!(ec.cov.yy, 0.02 * 0.02, epsilon = EPS);
assert_abs_diff_eq!(ec.cov.xy, 0.0, epsilon = EPS);
}
#[test]
fn to_cartesian_cov_position_matches() {
let c = EquCoord::from_degrees(37.0, 1e-5, -15.0, 1e-5);
let ec = EquCoordCov::from_equ(c);
let cc = ec.to_cartesian_cov();
let direct = crate::coordinates::cartesian::CartesianCoord::from(c);
assert_abs_diff_eq!(cc.coord.x, direct.x, epsilon = EPS);
assert_abs_diff_eq!(cc.coord.y, direct.y, epsilon = EPS);
assert_abs_diff_eq!(cc.coord.z, direct.z, epsilon = EPS);
}
#[test]
fn zero_errors_give_zero_cartesian_cov() {
let c = EquCoord::new(0.5, 0.0, 0.3, 0.0);
let cc = EquCoordCov::from_equ(c).to_cartesian_cov();
for &v in &[
cc.cov.xx, cc.cov.xy, cc.cov.xz, cc.cov.yy, cc.cov.yz, cc.cov.zz,
] {
assert!(v.abs() < 1e-30, "expected zero, got {v}");
}
}
#[test]
fn new_stores_fields() {
let c = EquCoord::new(1.0, 0.01, 0.5, 0.02);
let cov = Cov2 {
xx: 1e-4,
yy: 4e-4,
xy: 5e-5,
};
let ec = EquCoordCov::new(c, cov);
assert_eq!(ec.coord, c);
assert_eq!(ec.cov, cov);
}
}
}