pub mod detection;
pub mod footprint;
pub mod geometry;
pub mod survey;
pub use detection::{poisson_binomial_tail, DetectionModel, LinkingRequirement};
pub use footprint::{Footprint, SkyBox, FULL_SKY_DEG2};
pub use geometry::{
apparent_equatorial, apparent_equatorial_light_time, earth_positions_from_ephemeris,
earth_positions_with, ApparentPosition,
};
pub use survey::{Epoch, SurveyDefinition};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EpochState {
pub ra_deg: f64,
pub dec_deg: f64,
pub apparent_mag: f64,
}
pub fn epoch_detection_probabilities(
survey: &SurveyDefinition,
model: &DetectionModel,
states: &[EpochState],
) -> Vec<f64> {
assert_eq!(
states.len(),
survey.epochs.len(),
"one EpochState per survey epoch (survey '{}' has {} epochs)",
survey.name,
survey.epochs.len()
);
survey
.epochs
.iter()
.zip(states)
.map(|(epoch, state)| {
if survey.footprint.contains(state.ra_deg, state.dec_deg) {
let m50 = epoch.m50.unwrap_or(model.m50);
model.efficiency_at_depth(state.apparent_mag, m50)
} else {
0.0
}
})
.collect()
}
pub fn detection_probability(
survey: &SurveyDefinition,
model: &DetectionModel,
states: &[EpochState],
) -> f64 {
let probs = epoch_detection_probabilities(survey, model, states);
survey.linking.probability(&probs)
}
pub fn expected_completeness(orbit_probabilities: impl IntoIterator<Item = f64>) -> f64 {
let mut sum = 0.0;
let mut n = 0u64;
for p in orbit_probabilities {
sum += p;
n += 1;
}
if n == 0 {
0.0
} else {
sum / n as f64
}
}
pub fn combined_detection(per_survey_probabilities: &[f64]) -> f64 {
1.0 - per_survey_probabilities
.iter()
.map(|p| 1.0 - p)
.product::<f64>()
}
pub fn unique_contributions(per_survey_probabilities: &[f64]) -> Vec<f64> {
let mut none_so_far = 1.0;
per_survey_probabilities
.iter()
.map(|&p| {
let unique = p * none_so_far;
none_so_far *= 1.0 - p;
unique
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalogs::synthetic::orbits::{InclinationModel, SyntheticOrbitPopulation};
use crate::constants::GM_SUN;
use crate::keplerlib::KeplerOrbit;
use crate::time::{Time, Timescale};
use nalgebra::Vector3;
use rand::rngs::StdRng;
use rand::SeedableRng;
fn ecliptic_kepler(
a: f64,
e: f64,
i_deg: f64,
omega_big_deg: f64,
omega_deg: f64,
m_deg: f64,
epoch: &Time,
) -> KeplerOrbit {
KeplerOrbit::from_mean_anomaly(
a * (1.0 - e * e),
e,
i_deg,
omega_big_deg,
omega_deg,
m_deg,
epoch,
GM_SUN,
Some(10),
None,
)
}
fn circular_earth(t0_tt: f64) -> impl FnMut(&Time) -> Vector3<f64> {
move |t: &Time| {
let theta = std::f64::consts::TAU * (t.tt() - t0_tt) / 365.25;
Vector3::new(theta.cos(), theta.sin(), 0.0)
}
}
fn toy_survey(
ts: &Timescale,
footprint: Footprint,
n_epochs: usize,
k: u32,
) -> SurveyDefinition {
SurveyDefinition {
name: "toy".to_string(),
footprint,
epochs: (0..n_epochs)
.map(|j| Epoch::new(ts.tt_jd(2_456_000.0 + 180.0 * j as f64, None)))
.collect(),
linking: LinkingRequirement::KOfN { k },
}
}
fn states_for_orbit(
orbit: &KeplerOrbit,
survey: &SurveyDefinition,
earth_positions: &[Vector3<f64>],
mag: f64,
) -> Vec<EpochState> {
survey
.epochs
.iter()
.zip(earth_positions)
.map(|(epoch, earth)| {
let helio = orbit.at(&epoch.time).position;
let pos = apparent_equatorial(&helio, earth);
EpochState {
ra_deg: pos.ra_deg,
dec_deg: pos.dec_deg,
apparent_mag: mag,
}
})
.collect()
}
#[test]
fn full_sky_deep_survey_complete_on_bright_population() {
let ts = Timescale::default();
let survey = toy_survey(&ts, Footprint::AllSky, 8, 3);
let model = DetectionModel::new(24.0, 3.0, 1.0);
let earths = earth_positions_with(circular_earth(2_456_000.0), &survey.epoch_times());
let mut rng = StdRng::seed_from_u64(42);
let population = SyntheticOrbitPopulation::scattered_disk()
.with_count(50)
.generate(&mut rng)
.unwrap();
let epoch0 = ts.tt_jd(2_456_000.0, None);
let completeness = expected_completeness(population.iter().map(|o| {
let orbit = ecliptic_kepler(
o.a,
o.e,
o.i_deg,
o.omega_big_deg,
o.omega_deg,
o.mean_anomaly_deg,
&epoch0,
);
let states = states_for_orbit(&orbit, &survey, &earths, 18.0);
detection_probability(&survey, &model, &states)
}));
assert!(completeness > 0.999, "completeness = {completeness}");
}
#[test]
fn shallow_survey_blind_to_faint_population() {
let ts = Timescale::default();
let survey = toy_survey(&ts, Footprint::AllSky, 8, 3);
let model = DetectionModel::new(20.0, 3.0, 1.0);
let earths = earth_positions_with(circular_earth(2_456_000.0), &survey.epoch_times());
let mut rng = StdRng::seed_from_u64(42);
let population = SyntheticOrbitPopulation::scattered_disk()
.with_count(50)
.generate(&mut rng)
.unwrap();
let epoch0 = ts.tt_jd(2_456_000.0, None);
let completeness = expected_completeness(population.iter().map(|o| {
let orbit = ecliptic_kepler(
o.a,
o.e,
o.i_deg,
o.omega_big_deg,
o.omega_deg,
o.mean_anomaly_deg,
&epoch0,
);
let states = states_for_orbit(&orbit, &survey, &earths, 26.0);
detection_probability(&survey, &model, &states)
}));
assert!(completeness < 1e-6, "completeness = {completeness}");
}
#[test]
fn k_of_n_matches_hand_computed_binomial_tail() {
let ts = Timescale::default();
let survey = toy_survey(&ts, Footprint::AllSky, 10, 6);
let model = DetectionModel::new(22.0, 3.0, 1.0);
let states: Vec<EpochState> = (0..10)
.map(|_| EpochState {
ra_deg: 100.0,
dec_deg: 0.0,
apparent_mag: 22.0,
})
.collect();
let p = detection_probability(&survey, &model, &states);
assert!((p - 386.0 / 1024.0).abs() < 1e-12, "p = {p}");
let mut survey_north = survey;
survey_north.footprint = Footprint::DecRange {
dec_min_deg: -10.0,
dec_max_deg: 90.0,
};
let mut states_one_out = states;
states_one_out[3].dec_deg = -45.0;
let p9 = detection_probability(&survey_north, &model, &states_one_out);
assert!((p9 - 130.0 / 512.0).abs() < 1e-12, "p = {p9}");
}
#[test]
fn declination_footprint_distinguishes_ecliptic_longitudes() {
let ts = Timescale::default();
let footprint = Footprint::DecRange {
dec_min_deg: 5.0,
dec_max_deg: 90.0,
};
let survey = toy_survey(&ts, footprint, 1, 1);
let model = DetectionModel::new(24.0, 3.0, 1.0);
let epoch0 = ts.tt_jd(2_456_000.0, None);
let earths = vec![Vector3::zeros()];
let north = ecliptic_kepler(400.0, 1e-6, 0.0, 0.0, 0.0, 90.0, &epoch0);
let states = states_for_orbit(&north, &survey, &earths, 18.0);
assert!(states[0].dec_deg > 20.0, "dec = {}", states[0].dec_deg);
assert!(detection_probability(&survey, &model, &states) > 0.99);
let south = ecliptic_kepler(400.0, 1e-6, 0.0, 0.0, 0.0, 270.0, &epoch0);
let states = states_for_orbit(&south, &survey, &earths, 18.0);
assert!(states[0].dec_deg < -20.0, "dec = {}", states[0].dec_deg);
assert_eq!(detection_probability(&survey, &model, &states), 0.0);
let mut rng = StdRng::seed_from_u64(7);
let population = SyntheticOrbitPopulation::scattered_disk()
.with_count(400)
.with_inclination(InclinationModel::Planar)
.generate(&mut rng)
.unwrap();
let completeness = expected_completeness(population.iter().map(|o| {
let orbit = ecliptic_kepler(
o.a,
o.e,
o.i_deg,
o.omega_big_deg,
o.omega_deg,
o.mean_anomaly_deg,
&epoch0,
);
let states = states_for_orbit(&orbit, &survey, &earths, 18.0);
detection_probability(&survey, &model, &states)
}));
assert!(
(0.3..0.55).contains(&completeness),
"planar-population completeness = {completeness}"
);
}
#[test]
fn per_epoch_depth_overrides_apply() {
let ts = Timescale::default();
let mut survey = toy_survey(&ts, Footprint::AllSky, 2, 1);
survey.epochs[1].m50 = Some(20.0);
let model = DetectionModel::new(22.0, 3.0, 1.0);
let state = EpochState {
ra_deg: 10.0,
dec_deg: 10.0,
apparent_mag: 22.0,
};
let probs = epoch_detection_probabilities(&survey, &model, &[state, state]);
assert!((probs[0] - 0.5).abs() < 1e-12);
assert!(probs[1] < 0.01, "shallow epoch p = {}", probs[1]);
}
#[test]
fn expected_completeness_is_deterministic_mean() {
assert_eq!(expected_completeness(std::iter::empty::<f64>()), 0.0);
assert!((expected_completeness([0.2, 0.4, 0.9]) - 0.5).abs() < 1e-12);
let probs = [0.123, 0.456, 0.789, 0.0, 1.0];
assert_eq!(
expected_completeness(probs).to_bits(),
expected_completeness(probs).to_bits()
);
}
#[test]
fn combined_detection_is_per_orbit_or() {
assert_eq!(combined_detection(&[]), 0.0);
let p = combined_detection(&[0.5, 0.5]);
assert!((p - 0.75).abs() < 1e-12);
assert!((combined_detection(&[1.0, 0.1]) - 1.0).abs() < 1e-12);
}
#[test]
fn unique_contributions_sum_to_combined() {
let probs = [0.564, 0.3, 0.5];
let unique = unique_contributions(&probs);
assert_eq!(unique.len(), 3);
assert!((unique[0] - 0.564).abs() < 1e-12);
assert!((unique[1] - 0.3 * (1.0 - 0.564)).abs() < 1e-12);
let sum: f64 = unique.iter().sum();
assert!((sum - combined_detection(&probs)).abs() < 1e-12);
}
#[test]
#[should_panic(expected = "one EpochState per survey epoch")]
fn mismatched_state_count_panics() {
let ts = Timescale::default();
let survey = toy_survey(&ts, Footprint::AllSky, 3, 1);
let model = DetectionModel::new(22.0, 3.0, 1.0);
let state = EpochState {
ra_deg: 0.0,
dec_deg: 0.0,
apparent_mag: 20.0,
};
detection_probability(&survey, &model, &[state]);
}
#[test]
fn ephemeris_and_analytic_earth_agree_on_detection() {
let Ok(kernel) = crate::jplephem::SpiceKernel::open("test_data/de421.bsp") else {
eprintln!("skipping: test_data/de421.bsp not available");
return;
};
let mut ephemeris = crate::planetlib::Ephemeris::from_kernel(kernel);
let ts = Timescale::default();
let survey = toy_survey(&ts, Footprint::AllSky, 6, 2);
let times = survey.epoch_times();
let earths_eph = earth_positions_from_ephemeris(&mut ephemeris, ×).unwrap();
let earths_circ = earth_positions_with(circular_earth(2_456_000.0), ×);
let epoch0 = ts.tt_jd(2_456_000.0, None);
let orbit = ecliptic_kepler(400.0, 0.2, 17.0, 100.0, 150.0, 60.0, &epoch0);
let helio = orbit.at(&epoch0).position;
for (e_eph, e_circ) in earths_eph.iter().zip(&earths_circ) {
let p_eph = apparent_equatorial(&helio, e_eph);
let p_circ = apparent_equatorial(&helio, e_circ);
let dra = (p_eph.ra_deg - p_circ.ra_deg).abs().min(360.0);
let ddec = (p_eph.dec_deg - p_circ.dec_deg).abs();
assert!(dra < 0.4 && ddec < 0.4, "dra = {dra:.3}, ddec = {ddec:.3}");
}
}
}