pub mod bimap;
pub mod observatories;
use hifitime::ut1::Ut1Provider;
use hifitime::Epoch;
use nalgebra::{Matrix3, Vector3};
use ordered_float::NotNan;
use crate::constants::{Degree, Meter, EARTH_MAJOR_AXIS, EARTH_MINOR_AXIS, MJD};
use crate::constants::{DPI, ERAU};
use crate::earth_orientation::equequ;
use crate::outfit::Outfit;
use crate::outfit_errors::OutfitError;
use crate::ref_system::{rotmt, rotpn, RefEpoch, RefSystem};
use crate::time::gmst;
use std::fmt;
#[inline]
pub fn to_opt_notnan(x: Option<f64>) -> Result<Option<NotNan<f64>>, ordered_float::FloatIsNan> {
x.map(NotNan::new).transpose()
}
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub struct Observer {
pub longitude: NotNan<f64>,
pub rho_cos_phi: NotNan<f64>,
pub rho_sin_phi: NotNan<f64>,
pub name: Option<String>,
pub ra_accuracy: Option<NotNan<f64>>,
pub dec_accuracy: Option<NotNan<f64>>,
observer_fixed_coord: Vector3<NotNan<f64>>,
observer_velocity: Vector3<NotNan<f64>>,
}
impl Observer {
pub fn new(
longitude: Degree,
latitude: Degree,
elevation: Meter,
name: Option<String>,
ra_accuracy: Option<f64>,
dec_accuracy: Option<f64>,
) -> Result<Observer, OutfitError> {
let (rho_cos_phi, rho_sin_phi) = geodetic_to_parallax(latitude, elevation);
let omega: Vector3<NotNan<f64>> = Vector3::new(
NotNan::new(0.0)?,
NotNan::new(0.0)?,
NotNan::new(DPI * 1.00273790934)?,
);
let lon_radians = longitude.to_radians();
let body_fixed_coord: Vector3<NotNan<f64>> = Vector3::new(
NotNan::new(ERAU * rho_cos_phi * lon_radians.cos())?,
NotNan::new(ERAU * rho_cos_phi * lon_radians.sin())?,
NotNan::new(ERAU * rho_sin_phi)?,
);
let dvbf = omega.cross(&body_fixed_coord);
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: to_opt_notnan(ra_accuracy)?,
dec_accuracy: to_opt_notnan(dec_accuracy)?,
observer_fixed_coord: body_fixed_coord,
observer_velocity: dvbf,
})
}
pub fn from_parallax(
longitude: Degree,
rho_cos_phi: f64,
rho_sin_phi: f64,
name: Option<String>,
ra_accuracy: Option<f64>,
dec_accuracy: Option<f64>,
) -> Result<Observer, OutfitError> {
let omega: Vector3<NotNan<f64>> = Vector3::new(
NotNan::new(0.0)?,
NotNan::new(0.0)?,
NotNan::new(DPI * 1.00273790934)?,
);
let lon_radians = longitude.to_radians();
let body_fixed_coord: Vector3<NotNan<f64>> = Vector3::new(
NotNan::new(ERAU * rho_cos_phi * lon_radians.cos())?,
NotNan::new(ERAU * rho_cos_phi * lon_radians.sin())?,
NotNan::new(ERAU * rho_sin_phi)?,
);
let dvbf = omega.cross(&body_fixed_coord);
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: to_opt_notnan(ra_accuracy)?,
dec_accuracy: to_opt_notnan(dec_accuracy)?,
observer_fixed_coord: body_fixed_coord,
observer_velocity: dvbf,
})
}
pub fn body_fixed_coord(&self) -> Vector3<f64> {
let lon_radians = self.longitude.to_radians();
Vector3::new(
ERAU * self.rho_cos_phi.into_inner() * lon_radians.cos(),
ERAU * self.rho_cos_phi.into_inner() * lon_radians.sin(),
ERAU * self.rho_sin_phi.into_inner(),
)
}
pub fn pvobs(
&self,
tmjd: &Epoch,
ut1_provider: &Ut1Provider,
) -> Result<(Vector3<f64>, Vector3<f64>), OutfitError> {
let dxbf = self.observer_fixed_coord.map(|x| x.into_inner());
let dvbf = self.observer_velocity.map(|x| x.into_inner());
let mjd_ut1 = tmjd.to_ut1(ut1_provider);
let tut = mjd_ut1.to_mjd_tai_days();
let gast = gmst(tut) + equequ(tmjd.to_mjd_tt_days());
let rot = rotmt(-gast, 2);
let rer_sys1 = RefSystem::Equt(RefEpoch::Epoch(tmjd.to_mjd_tt_days()));
let rer_sys2 = RefSystem::Eclm(RefEpoch::J2000);
let rot1 = rotpn(&rer_sys1, &rer_sys2)?;
let rot1_mat = rot1.transpose();
let rot_mat = rot.transpose();
let rotmat = rot1_mat * rot_mat;
let dx = rotmat * dxbf;
let dv = rotmat * dvbf;
Ok((dx, dv))
}
pub fn helio_position(
&self,
state: &Outfit,
epoch: &Epoch,
observer_geocentric_position: &Vector3<f64>,
) -> Result<Vector3<f64>, OutfitError> {
let jpl = state.get_jpl_ephem().unwrap();
let earth_pos = jpl.earth_ephemeris(epoch, false).0;
let rot_matrix = state.get_rot_eclmj2000_to_equmj2000().transpose();
Ok(earth_pos + rot_matrix * observer_geocentric_position)
}
pub fn geodetic_lat_height_wgs84(&self) -> (f64, f64) {
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.to_degrees(), h)
}
}
impl fmt::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();
let rc = self.rho_cos_phi.into_inner();
let rs = self.rho_sin_phi.into_inner();
let phi_geo_deg = rs.atan2(rc).to_degrees();
let rho_re = rc.hypot(rs);
const RAD2AS: f64 = 206_264.806_247_096_36;
let ra_sigma_as = self
.ra_accuracy
.map(|v| format!("{:.3}″", v.into_inner() * RAD2AS))
.unwrap_or_else(|| "—".to_string());
let dec_sigma_as = self
.dec_accuracy
.map(|v| format!("{:.3}″", v.into_inner() * RAD2AS))
.unwrap_or_else(|| "—".to_string());
if f.alternate() {
writeln!(
f,
"{name} (lon: {lon_deg:.6}°, lat: {lat_deg:.6}° geodetic, elev: {h_m:.2} m)"
)?;
writeln!(
f,
" parallax: ρ·cosφ={rc:.9}, ρ·sinφ={rs:+.9} | φ_geo={phi_geo_deg:+.6}° ρ={rho_re:.6} RE"
)?;
write!(f, " astrometric 1σ: RA={ra_sigma_as}, DEC={dec_sigma_as}")
} else {
write!(
f,
"{name} (lon: {lon_deg:.6}°, lat: {lat_deg:.6}° geodetic, elev: {h_m:.2} m)"
)
}
}
}
pub fn lat_alt_to_parallax(lat: f64, height: f64) -> (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)
}
pub fn geodetic_to_parallax(lat: f64, height: f64) -> (f64, f64) {
let latitude_rad = lat.to_radians();
let (rho_cos_phi, rho_sin_phi) = lat_alt_to_parallax(latitude_rad, height);
(rho_cos_phi, rho_sin_phi)
}
pub fn helio_obs_pos(
observers: [&Observer; 3],
mjd_tt: &Vector3<MJD>,
state: &Outfit,
) -> Result<Matrix3<f64>, OutfitError> {
let epochs = [
Epoch::from_mjd_in_time_scale(mjd_tt.x, hifitime::TimeScale::TT),
Epoch::from_mjd_in_time_scale(mjd_tt.y, hifitime::TimeScale::TT),
Epoch::from_mjd_in_time_scale(mjd_tt.z, hifitime::TimeScale::TT),
];
let pvobs1 = observers[0].pvobs(&epochs[0], state.get_ut1_provider())?;
let pvobs2 = observers[1].pvobs(&epochs[1], state.get_ut1_provider())?;
let pvobs3 = observers[2].pvobs(&epochs[2], state.get_ut1_provider())?;
let positions = [
observers[0].helio_position(state, &epochs[0], &pvobs1.0)?,
observers[1].helio_position(state, &epochs[1], &pvobs2.0)?,
observers[2].helio_position(state, &epochs[2], &pvobs3.0)?,
];
Ok(Matrix3::from_columns(&positions))
}
#[cfg(test)]
mod observer_test {
use crate::{error_models::ErrorModel, outfit::Outfit};
use super::*;
#[test]
fn test_observer_constructor() {
let observer = Observer::new(0.0, 0.0, 0.0, None, None, None).unwrap();
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,
-30.2446,
2647.,
Some("Rubin Observatory".to_string()),
Some(0.0001),
Some(0.0001),
)
.unwrap();
assert_eq!(observer.longitude, 289.25058);
assert_eq!(observer.rho_cos_phi, 0.8649760504617418);
assert_eq!(observer.rho_sin_phi, -0.5009551027512434);
}
#[test]
fn body_fixed_coord_test() {
let (lon, lat, h) = (203.744090000, 20.707233557, 3067.694);
let pan_starrs = Observer::new(lon, lat, h, None, None, None).unwrap();
assert_eq!(
pan_starrs.body_fixed_coord(),
Vector3::new(
-0.00003653799439776371,
-0.00001607260397528885,
0.000014988110430544328
)
);
assert_eq!(
pan_starrs.observer_fixed_coord,
Vector3::new(
NotNan::new(-0.00003653799439776371).unwrap(),
NotNan::new(-0.00001607260397528885).unwrap(),
NotNan::new(0.000014988110430544328).unwrap()
)
)
}
#[test]
fn pvobs_test() {
let state = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap();
let tmjd = 57028.479297592596;
let epoch = Epoch::from_mjd_in_time_scale(tmjd, hifitime::TimeScale::TT);
let (lon, lat, h) = (203.744090000, 20.707233557, 3067.694);
let pan_starrs =
Observer::new(lon, lat, h, Some("Pan-STARRS 1".to_string()), None, None).unwrap();
let (observer_position, observer_velocity) =
&pan_starrs.pvobs(&epoch, state.get_ut1_provider()).unwrap();
assert_eq!(
observer_position.as_slice(),
[
-2.086211182493635e-5,
3.718476815327979e-5,
2.4978996447997476e-7
]
);
assert_eq!(
observer_velocity.as_slice(),
[
-0.0002143246535691577,
-0.00012059801691431748,
5.262184624215718e-5
]
);
}
#[test]
fn geodetic_to_parallax_test() {
let (pxy1, pz1) = geodetic_to_parallax(20.707233557, 3067.694);
assert_eq!(pxy1, 0.9362410003211518);
assert_eq!(pz1, 0.35154299856304305);
}
#[test]
#[cfg(feature = "jpl-download")]
fn test_helio_pos_obs() {
use crate::unit_test_global::OUTFIT_HORIZON_TEST;
let tmjd = Vector3::new(
57028.479297592596,
57_049.245_147_592_59,
57_063.977_117_592_59,
);
let (lon, lat, h) = (203.744090000, 20.707233557, 3067.694);
let pan_starrs =
Observer::new(lon, lat, h, Some("Pan-STARRS 1".to_string()), None, None).unwrap();
let observers = [&pan_starrs, &pan_starrs, &pan_starrs];
let helio_pos = helio_obs_pos(observers, &tmjd, &OUTFIT_HORIZON_TEST.0).unwrap();
assert_eq!(
helio_pos.as_slice(),
[
-0.2645666171464416,
0.8689351643701766,
0.3766996211107864,
-0.5891631852137064,
0.7238872516824697,
0.3138186516540669,
-0.7743280306286537,
0.5612532665812755,
0.24333415479994636
]
);
}
#[cfg(test)]
mod geodetic_inverse_tests {
use super::*;
use crate::constants::Degree;
use approx::assert_abs_diff_eq;
fn roundtrip_site(name: &str, lon_deg: Degree, lat_deg: Degree, h_m: f64) {
let obs = Observer::new(lon_deg, lat_deg, h_m, Some(name.to_string()), None, None)
.expect("Failed to create observer");
let (lat_rec_deg, h_rec_m) = obs.geodetic_lat_height_wgs84();
let tol_lat_deg = 1e-6;
let tol_h_m = 1e-2;
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, Degree, Degree, f64)] = &[
("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_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);
Observer::new(
lon_deg,
lat_deg,
elev_m, name.map(|s| s.to_string()),
ra_sigma,
dec_sigma,
)
.expect("Failed to create Observer")
}
#[test]
fn display_compact_single_line() {
let obs = make_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_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_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_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_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}"
);
}
}
}