pub mod dataset;
pub mod error_model;
pub mod mpc;
use std::fmt::{self, Display};
use thiserror::Error;
use ordered_float::NotNan;
use crate::{
Degrees, Meters, Radians, ToNotNan,
constants::{EARTH_MAJOR_AXIS, EARTH_MINOR_AXIS},
};
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Observer {
pub longitude: NotNan<Radians>,
pub rho_cos_phi: NotNan<f64>,
pub rho_sin_phi: NotNan<f64>,
pub name: Option<String>,
pub ra_accuracy: Option<NotNan<Radians>>,
pub dec_accuracy: Option<NotNan<Radians>>,
}
#[derive(Debug, Error)]
pub enum ObserverError {
#[error("Invalid floating-point value (NaN encountered): {0}")]
InvalidFloatValue(ordered_float::FloatIsNan),
#[error("MPC code not found in lookup: {0:?}")]
MpcCodeNotFound([u8; 3]),
#[error("Invalid MPC code format: {0}")]
InvalidMpcCode(String),
#[error("Missing MPC code for observer row")]
MissingMpcCode,
}
impl From<ordered_float::FloatIsNan> for ObserverError {
fn from(e: ordered_float::FloatIsNan) -> Self {
ObserverError::InvalidFloatValue(e)
}
}
impl Observer {
pub fn new(
longitude: Radians,
latitude: Radians,
elevation: Meters,
name: Option<String>,
ra_accuracy: Option<Radians>,
dec_accuracy: Option<Radians>,
) -> Result<Observer, ObserverError> {
let (rho_cos_phi, rho_sin_phi) = geodetic_to_parallax(latitude, elevation);
Observer::from_parallax(
longitude,
rho_cos_phi,
rho_sin_phi,
name,
ra_accuracy,
dec_accuracy,
)
}
pub fn from_parallax(
longitude: Radians,
rho_cos_phi: f64,
rho_sin_phi: f64,
name: Option<String>,
ra_accuracy: Option<f64>,
dec_accuracy: Option<f64>,
) -> Result<Observer, ObserverError> {
Ok(Observer {
longitude: NotNan::try_from(longitude)?,
rho_cos_phi: NotNan::try_from(rho_cos_phi)?,
rho_sin_phi: NotNan::try_from(rho_sin_phi)?,
name,
ra_accuracy: ra_accuracy.to_notnan()?,
dec_accuracy: dec_accuracy.to_notnan()?,
})
}
pub fn geodetic_lat_height_wgs84(&self) -> (Radians, Meters) {
let a = EARTH_MAJOR_AXIS;
let b = EARTH_MINOR_AXIS;
let e2 = 1.0 - (b * b) / (a * a);
let ep2 = (a * a) / (b * b) - 1.0;
let p = self.rho_cos_phi.into_inner() * a; let z = self.rho_sin_phi.into_inner() * a;
let theta = (z * a).atan2(p * b);
let st = theta.sin();
let ct = theta.cos();
let phi = (z + ep2 * b * st.powi(3)).atan2(p - e2 * a * ct.powi(3));
let s = phi.sin();
let n = a / (1.0 - e2 * s * s).sqrt();
let h = p / phi.cos() - n;
(phi, h)
}
pub fn geocentric_lat_deg(&self) -> Degrees {
let rc = self.rho_cos_phi.into_inner();
let rs = self.rho_sin_phi.into_inner();
rs.atan2(rc).to_degrees()
}
pub fn geocentric_distance_earth_radii(&self) -> f64 {
let rc = self.rho_cos_phi.into_inner();
let rs = self.rho_sin_phi.into_inner();
rc.hypot(rs)
}
}
pub fn geodetic_to_parallax(lat: Radians, height: Meters) -> (f64, f64) {
let axis_ratio = EARTH_MINOR_AXIS / EARTH_MAJOR_AXIS;
let u = (lat.sin() * axis_ratio).atan2(lat.cos());
let rho_sin_phi = axis_ratio * u.sin() + (height / EARTH_MAJOR_AXIS) * lat.sin();
let rho_cos_phi = u.cos() + (height / EARTH_MAJOR_AXIS) * lat.cos();
(rho_cos_phi, rho_sin_phi)
}
impl Display for Observer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = self.name.as_deref().unwrap_or("Unnamed");
let (lat_deg, h_m) = self.geodetic_lat_height_wgs84();
let lon_deg = self.longitude.into_inner();
write!(
f,
"{name} (lon: {lon_deg:.6}°, lat: {lat_deg:.6}° geodetic, elev: {h_m:.2} m)"
)?;
if !f.alternate() {
return Ok(());
}
let rc = self.rho_cos_phi.into_inner();
let rs = self.rho_sin_phi.into_inner();
let fmt_accuracy = |v: Option<NotNan<f64>>| {
v.map_or_else(
|| "—".to_string(),
|v| format!("{:.3}″", v.into_inner().to_degrees() * 3600.0),
)
};
writeln!(f)?;
writeln!(
f,
" parallax: ρ·cosφ={rc:.9}, ρ·sinφ={rs:+.9} | φ_geo={:+.6}° ρ={:.6} RE",
self.geocentric_lat_deg(),
self.geocentric_distance_earth_radii(),
)?;
write!(
f,
" astrometric 1σ: RA={}, DEC={}",
fmt_accuracy(self.ra_accuracy),
fmt_accuracy(self.dec_accuracy),
)
}
}
#[cfg(test)]
mod observer_test {
use super::*;
fn to_dynamic_observer(
longitude: Radians,
latitude: Radians,
height: Meters,
name: Option<String>,
ra_accuracy: Option<f64>,
dec_accuracy: Option<f64>,
) -> Observer {
Observer::new(longitude, latitude, height, name, ra_accuracy, dec_accuracy)
.expect("Failed to create Observer")
}
#[test]
fn test_observer_constructor() {
let observer = to_dynamic_observer(0.0, 0.0, 0.0, None, None, None);
assert_eq!(observer.longitude, 0.0);
assert_eq!(observer.rho_cos_phi, 1.0);
assert_eq!(observer.rho_sin_phi, 0.0);
let observer = Observer::new(
289.25058_f64.to_radians(),
-30.2446_f64.to_radians(),
2647.,
Some("Rubin Observatory".to_string()),
Some(0.0001),
Some(0.0001),
)
.unwrap();
assert_eq!(observer.longitude, 289.25058_f64.to_radians());
assert_eq!(observer.rho_cos_phi, 0.8649760504617418);
assert_eq!(observer.rho_sin_phi, -0.5009551027512434);
}
#[test]
fn geodetic_to_parallax_test() {
let (pxy1, pz1) = geodetic_to_parallax(20.707233557_f64.to_radians(), 3067.694);
assert_eq!(pxy1, 0.9362410003211518);
assert_eq!(pz1, 0.35154299856304305);
}
#[cfg(test)]
mod geodetic_inverse_tests {
use super::*;
use approx::assert_abs_diff_eq;
fn roundtrip_site(name: &str, lon_deg: Degrees, lat_deg: Degrees, h_m: Meters) {
let obs = to_dynamic_observer(
lon_deg.to_radians(),
lat_deg.to_radians(),
h_m,
Some(name.to_string()),
None,
None,
);
let (lat_rec_rad, h_rec_m) = obs.geodetic_lat_height_wgs84();
let tol_lat_deg = 1e-6;
let tol_h_m = 1e-2;
let lat_rec_deg = lat_rec_rad.to_degrees();
assert_abs_diff_eq!(lat_rec_deg, lat_deg, epsilon = tol_lat_deg);
assert_abs_diff_eq!(h_rec_m, h_m, epsilon = tol_h_m);
}
#[test]
fn geodetic_roundtrip_known_observatories_wgs84() {
let sites: &[(&str, Degrees, Degrees, Meters)] = &[
("Haleakala (PS1 I41)", -156.2575, 20.7075, 3055.0),
("Mauna Kea (CFHT)", -155.4700, 19.8261, 4205.0),
("ESO Paranal", -70.4025, -24.6252, 2635.0),
("Cerro Pachon (Rubin)", -70.7366, -30.2407, 2663.0),
("La Silla", -70.7346, -29.2613, 2400.0),
("Kitt Peak", -111.5967, 31.9583, 2096.0),
("Roque de los Muchachos", -17.8947, 28.7606, 2396.0),
];
for (name, lon, lat, h_m) in sites.iter().copied() {
roundtrip_site(name, lon, lat, h_m);
}
}
#[test]
fn geodetic_roundtrip_extremes_equator_and_pole() {
roundtrip_site("Equator (0°, 0 m)", 0.0, 0.0, 0.0);
roundtrip_site("Near North Pole", 0.0, 89.999, 1000.0);
roundtrip_site("Near South Pole", 0.0, -89.999, 1000.0);
}
#[test]
fn geodetic_roundtrip_high_altitude_and_negative() {
roundtrip_site("High Alt 10 m", 10.0, 45.0, 10_000.0);
roundtrip_site("Below ellipsoid -50 m", -30.0, -10.0, -50.0);
}
}
#[cfg(test)]
mod observer_display_tests {
use super::*;
#[inline]
fn arcsec_to_rad(as_val: f64) -> f64 {
std::f64::consts::PI / (180.0 * 3600.0) * as_val
}
fn make_dynamic_observer_with_acc(
name: Option<&str>,
lon_deg: f64,
lat_deg: f64,
elev_m: f64,
ra_as: Option<f64>,
dec_as: Option<f64>,
) -> Observer {
let ra_sigma = ra_as.map(arcsec_to_rad);
let dec_sigma = dec_as.map(arcsec_to_rad);
to_dynamic_observer(
lon_deg,
lat_deg,
elev_m, name.map(|s| s.to_string()),
ra_sigma,
dec_sigma,
)
}
#[test]
fn display_compact_single_line() {
let obs = make_dynamic_observer_with_acc(Some("TestSite"), 10.0, 0.0, 0.0, None, None);
let s = format!("{obs}");
assert!(
!s.contains('\n'),
"Compact format should be single-line, got:\n{s}"
);
assert!(
s.contains("TestSite (lon: 10.000000°"),
"Missing name/lon fragment. Got:\n{s}"
);
assert!(
s.contains("lat: 0.000000° geodetic"),
"Missing geodetic latitude fragment. Got:\n{s}"
);
assert!(
s.contains("elev: 0.00 m"),
"Missing elevation fragment (m). Got:\n{s}"
);
}
#[test]
fn display_verbose_multiline_with_sections() {
let obs = make_dynamic_observer_with_acc(
Some("VerboseSite"),
-70.0,
-30.0,
2400.0,
None,
None,
);
let s = format!("{obs:#}");
assert!(
s.contains('\n'),
"Verbose format should be multi-line. Got:\n{s}"
);
assert!(
s.starts_with("VerboseSite (lon: -70.000000°"),
"First line should start with site name and lon. Got:\n{s}"
);
assert!(
s.contains("\n parallax: ρ·cosφ="),
"Missing 'parallax:' line. Got:\n{s}"
);
assert!(
s.contains("φ_geo=") && s.contains("ρ="),
"Missing φ_geo/ρ fragments. Got:\n{s}"
);
assert!(
s.contains("\n astrometric 1σ: RA=—, DEC=—"),
"Missing astrometric 1σ line with em-dashes for None. Got:\n{s}"
);
}
#[test]
fn display_verbose_shows_ra_dec_sigmas() {
let obs = make_dynamic_observer_with_acc(
Some("AccSite"),
0.0,
0.0,
0.0,
Some(1.0),
Some(2.5),
);
let s = format!("{obs:#}");
assert!(
s.contains("astrometric 1σ: RA=1.000″, DEC=2.500″"),
"Expected RA/DEC sigma in arcseconds with 3 decimals. Got:\n{s}"
);
}
#[test]
fn display_uses_unnamed_when_missing() {
let obs = make_dynamic_observer_with_acc(None, 5.0, 0.0, 0.0, None, None);
let s = format!("{obs}");
assert!(
s.starts_with("Unnamed (lon: 5.000000°"),
"Expected 'Unnamed' fallback. Got:\n{s}"
);
}
#[test]
fn display_elevation_shown_in_km() {
let obs = make_dynamic_observer_with_acc(
Some("Haleakala-ish"),
-156.2575,
20.7075,
3055.0,
None,
None,
);
let s = format!("{obs}");
assert!(
s.contains("elev: 3055.00 m"),
"Expected elevation ~3055 m rounded to 2 decimals. Got:\n{s}"
);
assert!(
s.contains("lon: -156.257500°"),
"Longitude formatting mismatch. Got:\n{s}"
);
}
}
}