pub mod display;
pub mod observations_ext;
pub mod triplets_generator;
pub mod triplets_iod;
use crate::{
constants::{Observations, Radian, DPI, JDTOMJD, MJD, RAD2ARC, VLIGHT_AU},
conversion::{cartesian_to_radec, dec_sdms_prec, fmt_vec3_au, ra_hms_prec},
observers::Observer,
orbit_type::equinoctial_element::EquinoctialElements,
outfit::Outfit,
outfit_errors::OutfitError,
time::{fmt_ss, iso_tt_from_epoch, iso_utc_from_epoch},
};
use hifitime::{Epoch, TimeScale};
use nalgebra::Vector3;
use std::{f64::consts::PI, fmt};
#[derive(Debug, Clone, PartialEq, Copy)]
pub struct Observation {
pub(crate) observer: u16,
pub ra: Radian,
pub error_ra: Radian,
pub dec: Radian,
pub error_dec: Radian,
pub time: MJD,
pub(crate) observer_earth_position: Vector3<f64>,
pub(crate) observer_helio_position: Vector3<f64>,
}
impl Observation {
pub fn new(
state: &Outfit,
observer: u16,
ra: Radian,
error_ra: Radian,
dec: Radian,
error_dec: Radian,
time: MJD,
) -> Result<Self, OutfitError> {
let obs_mjd = Epoch::from_mjd_in_time_scale(time, hifitime::TimeScale::TT);
let obs = state.get_observer_from_uint16(observer);
let (geo_obs_pos, _) = obs.pvobs(&obs_mjd, state.get_ut1_provider())?;
let helio_obs_pos = obs.helio_position(state, &obs_mjd, &geo_obs_pos)?;
Ok(Observation {
observer,
ra,
error_ra,
dec,
error_dec,
time,
observer_earth_position: geo_obs_pos,
observer_helio_position: helio_obs_pos,
})
}
#[allow(clippy::too_many_arguments)]
pub fn with_positions(
observer: u16,
ra: Radian,
error_ra: Radian,
dec: Radian,
error_dec: Radian,
time: MJD,
observer_earth_position: Vector3<f64>,
observer_helio_position: Vector3<f64>,
) -> Self {
Self {
observer,
ra,
error_ra,
dec,
error_dec,
time,
observer_earth_position,
observer_helio_position,
}
}
pub fn get_observer_helio_position(&self) -> Vector3<f64> {
self.observer_helio_position
}
pub fn get_observer_earth_position(&self) -> Vector3<f64> {
self.observer_earth_position
}
pub fn get_observer<'a>(&self, env_state: &'a Outfit) -> &'a Observer {
env_state.get_observer_from_uint16(self.observer)
}
pub fn compute_apparent_position(
&self,
state: &Outfit,
equinoctial_element: &EquinoctialElements,
) -> Result<(f64, f64), OutfitError> {
if equinoctial_element.eccentricity() >= 1.0 {
return Err(OutfitError::InvalidOrbit(
"Eccentricity >= 1 is not yet supported".to_string(),
));
}
let (cart_pos_ast, cart_pos_vel, _) = equinoctial_element.solve_two_body_problem(
0.,
self.time - equinoctial_element.reference_epoch,
false,
)?;
let obs_mjd = Epoch::from_mjd_in_time_scale(self.time, hifitime::TimeScale::TT);
let (earth_position, _) = state.get_jpl_ephem()?.earth_ephemeris(&obs_mjd, false);
let matrix_elc_transform = state.get_rot_equmj2000_to_eclmj2000();
let earth_pos_eclj2000 = matrix_elc_transform.transpose() * earth_position;
let cart_pos_ast_eclj2000 = matrix_elc_transform * cart_pos_ast;
let cart_pos_vel_eclj2000 = matrix_elc_transform * cart_pos_vel;
let geo_obs_pos = self.observer_earth_position;
let xobs = geo_obs_pos + earth_pos_eclj2000;
let obs_on_earth = matrix_elc_transform * xobs;
let relative_position = cart_pos_ast_eclj2000 - obs_on_earth;
let corrected_pos = correct_aberration(relative_position, cart_pos_vel_eclj2000);
let (alpha, delta, _) = cartesian_to_radec(corrected_pos);
Ok((alpha, delta))
}
pub fn ephemeris_error(
&self,
state: &Outfit,
equinoctial_element: &EquinoctialElements,
) -> Result<f64, OutfitError> {
let (alpha, delta) = self.compute_apparent_position(state, equinoctial_element)?;
let mut diff_alpha = (self.ra - alpha) % DPI;
if diff_alpha > PI {
diff_alpha -= DPI;
}
let diff_delta = self.dec - delta;
let rms_ra = (self.dec.cos() * (diff_alpha / self.error_ra)).powi(2);
let rms_dec = (diff_delta / self.error_dec).powi(2);
Ok(rms_ra + rms_dec)
}
}
pub fn correct_aberration(xrel: Vector3<f64>, vrel: Vector3<f64>) -> Vector3<f64> {
let norm_vector = xrel.norm();
let dt = norm_vector / VLIGHT_AU;
xrel - dt * vrel
}
impl fmt::Display for Observation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let ra_rad: f64 = self.ra;
let dec_rad: f64 = self.dec;
let sra_as: f64 = self.error_ra * RAD2ARC;
let sdec_as: f64 = self.error_dec * RAD2ARC;
let mjd_tt: f64 = self.time;
let jd_tt = mjd_tt + JDTOMJD;
let sec_prec = 3;
let pos_prec = 6;
let (rh, rm, rs) = ra_hms_prec(ra_rad, sec_prec);
let (sgn, dd, dm, ds) = dec_sdms_prec(dec_rad, sec_prec);
let rs_s = fmt_ss(rs, sec_prec);
let ds_s = fmt_ss(ds, sec_prec);
if f.alternate() {
let site = self.observer;
let r_geo = fmt_vec3_au(&self.observer_earth_position, pos_prec);
let r_hel = fmt_vec3_au(&self.observer_helio_position, pos_prec);
let epoch_tt = Epoch::from_mjd_in_time_scale(mjd_tt, TimeScale::TT);
let iso_tt = iso_tt_from_epoch(epoch_tt, sec_prec);
let iso_utc = iso_utc_from_epoch(epoch_tt, sec_prec);
writeln!(f, "Astrometric observation")?;
writeln!(f, "----------------------")?;
writeln!(f, "Site ID : {site}")?;
writeln!(f, "Epoch (TT) : MJD {mjd_tt:.6}, JD {jd_tt:.6}")?;
writeln!(f, "Epoch (ISO TT) : {iso_tt}")?;
writeln!(f, "Epoch (ISO UTC): {iso_utc}")?;
writeln!(
f,
"RA / σ : {rh:02}h {rm:02}m {rs_s}s (σ = {sra_as:.3}\" )"
)?;
writeln!(
f,
"DEC / σ : {sgn}{dd:02}° {dm:02}' {ds_s}\" (σ = {sdec_as:.3}\" )"
)?;
writeln!(f, "Observer (geo) : {r_geo}")?;
writeln!(f, "Observer (hel) : {r_hel}")
} else {
let site = self.observer;
let r_geo = fmt_vec3_au(&self.observer_earth_position, pos_prec);
let r_hel = fmt_vec3_au(&self.observer_helio_position, pos_prec);
write!(
f,
"Obs(site={site}, MJD={mjd_tt:.6} TT, RA={rh:02}h{rm:02}m{rs_s}s ± {sra_as:.3}\", \
DEC={sgn}{dd:02}°{dm:02}'{ds_s}\" ± {sdec_as:.3}\", r_geo={r_geo}, r_hel={r_hel})"
)
}
}
}
#[cfg(test)]
#[cfg(feature = "jpl-download")]
mod test_observations {
use crate::unit_test_global::OUTFIT_HORIZON_TEST;
use super::*;
mod tests_compute_apparent_position {
use super::*;
use crate::unit_test_global::OUTFIT_HORIZON_TEST;
use approx::assert_relative_eq;
fn simple_circular_elements(epoch: f64) -> EquinoctialElements {
EquinoctialElements {
reference_epoch: epoch,
semi_major_axis: 1.0,
eccentricity_sin_lon: 0.0,
eccentricity_cos_lon: 0.0,
tan_half_incl_sin_node: 0.0,
tan_half_incl_cos_node: 0.0,
mean_longitude: 0.0,
}
}
#[test]
fn test_compute_apparent_position_nominal() {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.uint16_from_mpc_code(&"F51".to_string());
let t_obs = 59000.0; let equinoctial = simple_circular_elements(t_obs);
let obs = Observation::new(state, observer_code, 0.0, 0.0, 0.0, 0.0, t_obs).unwrap();
let (ra, dec) = obs
.compute_apparent_position(state, &equinoctial)
.expect("Computation should succeed");
assert!(ra.is_finite());
assert!(dec.is_finite());
assert!((0.0..2.0 * std::f64::consts::PI).contains(&ra));
assert!((-std::f64::consts::FRAC_PI_2..std::f64::consts::FRAC_PI_2).contains(&dec));
}
#[test]
fn test_compute_apparent_position_same_epoch() {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.uint16_from_mpc_code(&"F51".to_string());
let t_epoch = 60000.0;
let equinoctial = simple_circular_elements(t_epoch);
let obs = Observation::new(state, observer_code, 0.0, 0.0, 0.0, 0.0, t_epoch).unwrap();
let (ra1, dec1) = obs.compute_apparent_position(state, &equinoctial).unwrap();
let (ra2, dec2) = obs.compute_apparent_position(state, &equinoctial).unwrap();
assert_relative_eq!(ra1, ra2, epsilon = 1e-14);
assert_relative_eq!(dec1, dec2, epsilon = 1e-14);
}
#[test]
fn test_apparent_position_for_distant_object() {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.uint16_from_mpc_code(&"F51".to_string());
let t_obs = 59000.0;
let mut equinoctial = simple_circular_elements(t_obs);
equinoctial.semi_major_axis = 100.0;
let obs = Observation::new(state, observer_code, 0.0, 0.0, 0.0, 0.0, t_obs).unwrap();
let (ra, dec) = obs
.compute_apparent_position(state, &equinoctial)
.expect("Should compute apparent position for distant object");
assert!(ra.is_finite());
assert!(dec.is_finite());
}
#[test]
fn test_compute_apparent_position_propagation_failure() {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.uint16_from_mpc_code(&"F51".to_string());
let equinoctial = EquinoctialElements {
reference_epoch: 59000.0,
semi_major_axis: -1.0, eccentricity_sin_lon: 0.0,
eccentricity_cos_lon: 0.0,
tan_half_incl_sin_node: 0.0,
tan_half_incl_cos_node: 0.0,
mean_longitude: 0.0,
};
let obs = Observation::new(state, observer_code, 0.0, 0.0, 0.0, 0.0, 59000.0).unwrap();
let result = obs.compute_apparent_position(state, &equinoctial);
assert!(result.is_err(), "Invalid elements should trigger an error");
}
mod proptests_apparent_position {
use std::sync::Arc;
use super::*;
use proptest::prelude::*;
fn arb_equinoctial_elements() -> impl Strategy<Value = EquinoctialElements> {
(
58000.0..62000.0, 0.5..30.0, -0.5..0.5, -0.5..0.5, -0.5..0.5, -0.5..0.5, 0.0..(2.0 * std::f64::consts::PI), )
.prop_map(|(epoch, a, h, k, p, q, lambda)| {
EquinoctialElements {
reference_epoch: epoch,
semi_major_axis: a,
eccentricity_sin_lon: h,
eccentricity_cos_lon: k,
tan_half_incl_sin_node: p,
tan_half_incl_cos_node: q,
mean_longitude: lambda,
}
})
}
fn arb_observer() -> impl Strategy<Value = Observer> {
(-180.0..180.0, -90.0..90.0, 0.0..5.0).prop_map(|(lon, lat, elev)| {
Observer::new(lon, lat, elev, None, None, None).unwrap()
})
}
fn arb_extreme_equinoctial_elements() -> impl Strategy<Value = EquinoctialElements> {
(
58000.0..62000.0,
0.1..50.0,
-0.99..0.99,
-0.99..0.99,
-1.0..1.0,
-1.0..1.0,
0.0..(2.0 * std::f64::consts::PI),
)
.prop_map(|(epoch, a, h, k, p, q, lambda)| EquinoctialElements {
reference_epoch: epoch,
semi_major_axis: a,
eccentricity_sin_lon: h,
eccentricity_cos_lon: k,
tan_half_incl_sin_node: p,
tan_half_incl_cos_node: q,
mean_longitude: lambda,
})
.prop_filter(
"Only bound (elliptical) orbits are supported",
|elem: &EquinoctialElements| {
let e = (elem.eccentricity_sin_lon.powi(2)
+ elem.eccentricity_cos_lon.powi(2))
.sqrt();
e < 0.99
},
)
}
proptest! {
#[test]
fn proptest_ra_dec_are_finite_and_in_range(
equinoctial in arb_equinoctial_elements(),
obs_time in 58000.0f64..62000.0
) {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.uint16_from_mpc_code(&"F51".to_string());
let obs = Observation::new(
state,
observer_code,
0.0,
0.0,
0.0,
0.0,
obs_time,
).unwrap();
let result = obs.compute_apparent_position(state, &equinoctial);
if let Ok((ra, dec)) = result {
prop_assert!(ra.is_finite());
prop_assert!(dec.is_finite());
prop_assert!((0.0..2.0 * std::f64::consts::PI).contains(&ra));
prop_assert!((-std::f64::consts::FRAC_PI_2..std::f64::consts::FRAC_PI_2).contains(&dec));
}
}
#[test]
fn proptest_repeatability(
equinoctial in arb_equinoctial_elements(),
obs_time in 58000.0f64..62000.0
) {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.uint16_from_mpc_code(&"F51".to_string());
let obs = Observation::new(
state,
observer_code,
0.0,
0.0,
0.0,
0.0,
obs_time,
).unwrap();
let r1 = obs.compute_apparent_position(state, &equinoctial);
let r2 = obs.compute_apparent_position(state, &equinoctial);
prop_assert_eq!(r1, r2);
}
#[test]
fn proptest_small_time_change_has_small_effect(
equinoctial in arb_equinoctial_elements(),
obs_time in 58000.0f64..62000.0
) {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.uint16_from_mpc_code(&"F51".to_string());
let obs = Observation::new(
state,
observer_code,
0.0,
0.0,
0.0,
0.0,
obs_time,
).unwrap();
let obs_eps = Observation {
time: obs_time + 1e-3, ..obs
};
let r1 = obs.compute_apparent_position(state, &equinoctial);
let r2 = obs_eps.compute_apparent_position(state, &equinoctial);
if let (Ok((ra1, dec1)), Ok((ra2, dec2))) = (r1, r2) {
let dra = (ra1 - ra2).abs();
let ddec = (dec1 - dec2).abs();
prop_assert!(dra < 1.0);
prop_assert!(ddec < 1.0);
}
}
}
proptest! {
#[test]
fn proptest_ra_dec_valid_for_extreme_orbits_and_observers(
equinoctial in arb_extreme_equinoctial_elements(),
observer in arb_observer(),
obs_time in 58000.0f64..62000.0
) {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.add_observer_internal(Arc::new(observer));
let obs = Observation::new(
state,
observer_code,
0.0,
0.0,
0.0,
0.0,
obs_time,
).unwrap();
let result = obs.compute_apparent_position(state, &equinoctial);
if let Ok((ra, dec)) = result {
prop_assert!(ra.is_finite());
prop_assert!(dec.is_finite());
prop_assert!((0.0..2.0 * std::f64::consts::PI).contains(&ra));
prop_assert!((-std::f64::consts::FRAC_PI_2..std::f64::consts::FRAC_PI_2).contains(&dec));
}
}
}
#[test]
fn test_hyperbolic_orbit_returns_error() {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.uint16_from_mpc_code(&"F51".to_string());
let obs =
Observation::new(state, observer_code, 0.0, 0.0, 0.0, 0.0, 59000.0).unwrap();
let equinoctial = EquinoctialElements {
reference_epoch: 59000.0,
semi_major_axis: 1.0,
eccentricity_sin_lon: 0.8,
eccentricity_cos_lon: 0.8, tan_half_incl_sin_node: 0.0,
tan_half_incl_cos_node: 0.0,
mean_longitude: 0.0,
};
let result = obs.compute_apparent_position(state, &equinoctial);
assert!(
result.is_err(),
"Hyperbolic or parabolic orbits should currently return an error"
);
}
}
}
#[test]
fn test_new_observation() {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.uint16_from_mpc_code(&"F51".to_string());
let observation =
Observation::new(state, observer_code, 1.0, 0.1, 2.0, 0.2, 59000.0).unwrap();
assert_eq!(
observation,
Observation {
observer: 2,
ra: 1.0,
error_ra: 0.1,
dec: 2.0,
error_dec: 0.2,
time: 59000.0,
observer_earth_position: [
-1.4662164592060655e-6,
4.2560356749756634e-5,
-2.1126391698196086e-6
]
.into(),
observer_helio_position: [
-0.35113019606349866,
-0.8726512942340473,
-0.37829699890414364
]
.into(),
}
);
let observation_2 = Observation::new(
state,
2,
343.097_375,
2.777_777_777_777_778E-6,
-14.784833333333333,
2.777_777_777_777_778E-5,
59001.0,
)
.unwrap();
assert_eq!(
observation_2,
Observation {
observer: 2,
ra: 343.097375,
error_ra: 2.777777777777778e-6,
dec: -14.784833333333333,
error_dec: 2.777777777777778e-5,
time: 59001.0,
observer_earth_position: [
-2.1521316017998277e-6,
4.2531873012231404e-5,
-2.0988352183088593e-6
]
.into(),
observer_helio_position: [
-0.33522248840408125,
-0.8780465618894304,
-0.380635845615707
]
.into(),
}
);
}
mod tests_ephemeris_error {
use super::*;
use crate::unit_test_global::OUTFIT_HORIZON_TEST;
use approx::assert_relative_eq;
fn simple_equinoctial(epoch: f64) -> EquinoctialElements {
EquinoctialElements {
reference_epoch: epoch,
semi_major_axis: 1.0,
eccentricity_sin_lon: 0.0,
eccentricity_cos_lon: 0.0,
tan_half_incl_sin_node: 0.0,
tan_half_incl_cos_node: 0.0,
mean_longitude: 0.0,
}
}
#[test]
fn test_ephem_error() {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.uint16_from_mpc_code(&"F51".to_string());
let obs = Observation::new(
state,
observer_code,
1.7899347771316527,
1.770_024_520_608_546E-6,
0.778_996_538_107_973_6,
1.259_582_891_829_317_7E-6,
57070.262067592594,
)
.unwrap();
let equinoctial_element = EquinoctialElements {
reference_epoch: 57_049.242_334_573_75,
semi_major_axis: 1.8017360713154256,
eccentricity_sin_lon: 0.269_373_680_909_227_2,
eccentricity_cos_lon: 8.856_415_260_013_56E-2,
tan_half_incl_sin_node: 8.089_970_166_396_302E-4,
tan_half_incl_cos_node: 0.10168201109730375,
mean_longitude: 1.6936970079414786,
};
let rms_error = obs.ephemeris_error(&OUTFIT_HORIZON_TEST.0, &equinoctial_element);
assert_eq!(rms_error.unwrap(), 75.00445641224026);
}
#[test]
fn test_zero_error_when_positions_match() {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.uint16_from_mpc_code(&"F51".to_string());
let t_obs = 59000.0;
let equinoctial = simple_equinoctial(t_obs);
let obs = Observation::new(state, observer_code, 0.0, 1e-6, 0.0, 1e-6, t_obs).unwrap();
let (alpha, delta) = obs.compute_apparent_position(state, &equinoctial).unwrap();
let obs_match =
Observation::new(state, observer_code, alpha, 1e-6, delta, 1e-6, t_obs).unwrap();
let error = obs_match.ephemeris_error(state, &equinoctial).unwrap();
assert_relative_eq!(error, 0.0, epsilon = 1e-14);
}
#[test]
fn test_error_increases_with_offset() {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.uint16_from_mpc_code(&"F51".to_string());
let t_obs = 59000.0;
let equinoctial = simple_equinoctial(t_obs);
let base_obs = Observation {
observer: observer_code,
ra: 0.0,
error_ra: 1e-3,
dec: 0.0,
error_dec: 1e-3,
time: t_obs,
observer_earth_position: Vector3::zeros(),
observer_helio_position: Vector3::zeros(),
};
let (alpha, delta) = base_obs
.compute_apparent_position(state, &equinoctial)
.unwrap();
let obs_offset = Observation {
observer: 0,
ra: alpha + 1e-3,
error_ra: 1e-3,
dec: delta,
error_dec: 1e-3,
time: t_obs,
observer_earth_position: Vector3::zeros(),
observer_helio_position: Vector3::zeros(),
};
let err = obs_offset.ephemeris_error(state, &equinoctial).unwrap();
assert!(err > 0.0);
}
#[test]
fn test_ra_wrapping_invariance() {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.uint16_from_mpc_code(&"F51".to_string());
let t_obs = 59000.0;
let equinoctial = simple_equinoctial(t_obs);
let base_obs =
Observation::new(state, observer_code, 0.0, 1e-6, 0.0, 1e-6, t_obs).unwrap();
let (alpha, delta) = base_obs
.compute_apparent_position(state, &equinoctial)
.unwrap();
let obs_wrapped = Observation::new(
state,
observer_code,
alpha + std::f64::consts::TAU,
1e-6,
delta,
1e-6,
t_obs,
)
.unwrap();
let err1 = obs_wrapped.ephemeris_error(state, &equinoctial).unwrap();
assert_relative_eq!(err1, 0.0, epsilon = 1e-12);
}
#[test]
fn test_large_uncertainty_downweights_error() {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.uint16_from_mpc_code(&"F51".to_string());
let t_obs = 59000.0;
let equinoctial = simple_equinoctial(t_obs);
let base_obs =
Observation::new(state, observer_code, 0.0, 1.0, 0.0, 1.0, t_obs).unwrap();
let (alpha, delta) = base_obs
.compute_apparent_position(state, &equinoctial)
.unwrap();
let obs_large_uncertainty = Observation::new(
state,
observer_code,
alpha + 0.1,
10.0,
delta + 0.1,
10.0,
t_obs,
)
.unwrap();
let err = obs_large_uncertainty
.ephemeris_error(state, &equinoctial)
.unwrap();
assert!(
err < 1.0,
"Large uncertainties should reduce the error contribution"
);
}
mod proptests_ephemeris_error {
use std::sync::Arc;
use super::*;
use proptest::prelude::*;
fn arb_observer() -> impl Strategy<Value = Observer> {
(-180.0..180.0, -90.0..90.0, 0.0..5.0).prop_map(|(lon, lat, elev)| {
Observer::new(lon, lat, elev, None, None, None).unwrap()
})
}
fn arb_elliptical_equinoctial() -> impl Strategy<Value = EquinoctialElements> {
(
58000.0..62000.0,
0.5..20.0,
-0.8..0.8,
-0.8..0.8,
-0.8..0.8,
-0.8..0.8,
0.0..std::f64::consts::TAU,
)
.prop_map(|(epoch, a, h, k, p, q, l)| EquinoctialElements {
reference_epoch: epoch,
semi_major_axis: a,
eccentricity_sin_lon: h,
eccentricity_cos_lon: k,
tan_half_incl_sin_node: p,
tan_half_incl_cos_node: q,
mean_longitude: l,
})
.prop_filter("Bound orbits only", |e: &EquinoctialElements| {
e.eccentricity() < 1.0
})
}
proptest! {
#[test]
fn proptest_error_is_non_negative(
equinoctial in arb_elliptical_equinoctial(),
observer in arb_observer(),
obs_time in 58000.0f64..62000.0
) {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.add_observer_internal(Arc::new(observer));
let obs = Observation::new(state, observer_code,
0.0,
1e-3,
0.0,
1e-3,
obs_time,).unwrap();
let result = obs.ephemeris_error(state, &equinoctial);
if let Ok(val) = result {
prop_assert!(val.is_finite());
prop_assert!(val >= 0.0);
}
}
#[test]
fn proptest_error_downweights_large_uncertainties(
equinoctial in arb_elliptical_equinoctial(),
observer in arb_observer(),
obs_time in 58000.0f64..62000.0
) {
let state = &mut OUTFIT_HORIZON_TEST.0.clone();
let observer_code = state.add_observer_internal(Arc::new(observer));
let obs = Observation::new(state, observer_code,
0.5,
100.0,
0.5,
100.0,
obs_time,).unwrap();
let result = obs.ephemeris_error(state, &equinoctial);
if let Ok(val) = result {
prop_assert!(val < 1.0);
}
}
}
}
}
#[cfg(test)]
mod display_obs_tests {
use super::*;
use nalgebra::Vector3;
fn arcsec_to_rad(asx: f64) -> f64 {
asx / 206_264.806_247_096_37
}
#[allow(clippy::too_many_arguments)]
fn make_obs(
site: u16,
ra_rad: f64,
dec_rad: f64,
sra_arcsec: f64,
sdec_arcsec: f64,
mjd_tt: f64,
geo: (f64, f64, f64),
hel: (f64, f64, f64),
) -> Observation {
Observation {
observer: site,
ra: Radian::from(ra_rad),
error_ra: Radian::from(arcsec_to_rad(sra_arcsec)),
dec: Radian::from(dec_rad),
error_dec: Radian::from(arcsec_to_rad(sdec_arcsec)),
time: MJD::from(mjd_tt),
observer_earth_position: Vector3::new(geo.0, geo.1, geo.2),
observer_helio_position: Vector3::new(hel.0, hel.1, hel.2),
}
}
#[test]
fn display_compact_basic() {
let obs = make_obs(
809,
0.0,
0.0,
1.0,
1.0,
60000.123456, (0.123456789, -1.0, 0.000042),
(0.0, 1.234567, -0.5),
);
let s = format!("{obs}");
assert!(
s.contains("site=809"),
"site field not present/incorrect: {s}"
);
assert!(
s.contains("MJD=60000.123456"),
"MJD formatting to 6 decimals expected: {s}"
);
assert!(
s.contains("RA=00h00m00.000s ± 1.000\""),
"RA sexagesimal or sigma not formatted as expected: {s}"
);
assert!(
s.contains("DEC=+00°00'00.000\" ± 1.000\""),
"DEC sexagesimal or sigma not formatted as expected: {s}"
);
assert!(
s.contains("r_geo=[ 0.123457, -1.000000, 0.000042 ] AU"),
"Geocentric vector formatting/precision mismatch: {s}"
);
assert!(
s.contains("r_hel=[ 0.000000, 1.234567, -0.500000 ] AU"),
"Heliocentric vector formatting/precision mismatch: {s}"
);
}
#[test]
fn display_pretty_multiline() {
let ra_hours = 2.0 + 30.0 / 60.0 + 15.0 / 3600.0;
let ra_rad = ra_hours * std::f64::consts::PI / 12.0;
let dec_deg: f64 = 12.0 + 34.0 / 60.0 + 56.0 / 3600.0;
let dec_rad = dec_deg.to_radians();
let obs = make_obs(
500,
ra_rad,
dec_rad,
0.321, 0.789, 60200.5,
(1.0, 2.0, 3.0),
(-0.1, 0.2, -0.3),
);
let s = format!("{obs:#}");
assert!(
s.starts_with("Astrometric observation"),
"Pretty header missing: {s}"
);
assert!(
s.contains("Site ID : 500"),
"Pretty site line missing: {s}"
);
assert!(
s.contains("Epoch (TT) : MJD 60200.500000, JD 2460201.000000"),
"Epoch line with JD=MJD+2400000.5 expected: {s}"
);
assert!(s.contains("RA / σ : "), "RA line missing: {s}");
assert!(
s.contains("h") && s.contains("m") && s.contains("s"),
"RA HMS units missing: {s}"
);
assert!(
s.contains("(σ = 0.321"),
"RA sigma arcsec missing/incorrect: {s}"
);
assert!(s.contains("DEC / σ : +"), "DEC sign missing: {s}");
assert!(
s.contains("°") && s.contains("'") && s.contains("\""),
"DEC units missing: {s}"
);
assert!(
s.contains("(σ = 0.789"),
"DEC sigma arcsec missing/incorrect: {s}"
);
assert!(
s.contains("Observer (geo) : [ 1.000000, 2.000000, 3.000000 ] AU"),
"Geo vector line mismatch: {s}"
);
assert!(
s.contains("Observer (hel) : [ -0.100000, 0.200000, -0.300000 ] AU"),
"Hel vector line mismatch: {s}"
);
}
#[test]
fn ra_wraps_into_24h() {
let tiny = 1e-6;
let obs = make_obs(
1,
-tiny, 0.0,
0.1,
0.1,
59000.0,
(0.0, 0.0, 0.0),
(0.0, 0.0, 0.0),
);
let s = format!("{obs}");
assert!(
s.contains("RA=23h59m") || s.contains("RA=24h00m"),
"RA should wrap to [0, 24h): {s}"
);
assert!(
!s.contains("-"),
"RA string must not contain a negative sign after wrapping: {s}"
);
}
#[test]
fn dec_negative_sign_is_preserved() {
let dec_rad = (-10.0f64).to_radians();
let obs = make_obs(
2,
0.0,
dec_rad,
0.5,
0.5,
59000.0,
(0.0, 0.0, 0.0),
(0.0, 0.0, 0.0),
);
let s = format!("{obs}");
assert!(
s.contains("DEC=-10°00'00.000\""),
"Negative DEC sign or DMS formatting incorrect: {s}"
);
}
#[test]
fn uncertainties_are_in_arcseconds() {
let asx = 2.345;
let obs = make_obs(
3,
0.0,
0.0,
asx, asx,
60001.0,
(0.0, 0.0, 0.0),
(0.0, 0.0, 0.0),
);
let s1 = format!("{obs}");
let s2 = format!("{obs:#}");
assert!(
s1.contains("± 2.345\"") && s2.contains("(σ = 2.345"),
"Arcsecond uncertainties not printed as expected.\nCompact: {s1}\nPretty:\n{s2}"
);
}
#[test]
fn vector_precision_and_units() {
let obs = make_obs(
4,
0.0,
0.0,
1.0,
1.0,
60010.0,
(0.9999996, -0.9999996, 0.12345649),
(1.23456749, -1.23456751, 2.00000049),
);
let s = format!("{obs}");
assert!(
s.contains("r_geo=[ 1.000000, -1.000000, 0.123456 ] AU"),
"Geo rounding/units mismatch: {s}"
);
assert!(
s.contains("r_hel=[ 1.234567, -1.234568, 2.000000 ] AU"),
"Hel rounding/units mismatch: {s}"
);
}
}
}