use rand::Rng;
use crate::keplerlib::{mpcorb_orbit, KeplerOrbit};
use crate::time::Time;
use crate::{Result, StarfieldError};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SyntheticOrbit {
pub a: f64,
pub e: f64,
pub i_deg: f64,
pub omega_big_deg: f64,
pub omega_deg: f64,
pub mean_anomaly_deg: f64,
}
impl SyntheticOrbit {
pub fn perihelion(&self) -> f64 {
self.a * (1.0 - self.e)
}
pub fn aphelion(&self) -> f64 {
self.a * (1.0 + self.e)
}
pub fn longitude_of_perihelion_deg(&self) -> f64 {
(self.omega_deg + self.omega_big_deg).rem_euclid(360.0)
}
pub fn to_kepler_orbit(
&self,
epoch: &Time,
gm_km3_s2: f64,
target_name: Option<&str>,
) -> KeplerOrbit {
mpcorb_orbit(
self.a,
self.e,
self.i_deg,
self.omega_big_deg,
self.omega_deg,
self.mean_anomaly_deg,
epoch,
gm_km3_s2,
target_name,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum InclinationModel {
Planar,
HalfNormal {
sigma_deg: f64,
},
Rayleigh {
sigma_deg: f64,
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SyntheticOrbitPopulation {
pub count: usize,
pub a_min: f64,
pub a_max: f64,
pub q_min: f64,
pub q_max: f64,
pub inclination: InclinationModel,
}
impl Default for SyntheticOrbitPopulation {
fn default() -> Self {
Self {
count: 100,
a_min: 150.0,
a_max: 550.0,
q_min: 30.0,
q_max: 50.0,
inclination: InclinationModel::HalfNormal { sigma_deg: 15.0 },
}
}
}
impl SyntheticOrbitPopulation {
pub fn new() -> Self {
Self::default()
}
pub fn scattered_disk() -> Self {
Self::default()
}
pub fn with_count(mut self, count: usize) -> Self {
self.count = count;
self
}
pub fn with_semi_major_axis_range(mut self, a_min: f64, a_max: f64) -> Self {
self.a_min = a_min;
self.a_max = a_max;
self
}
pub fn with_perihelion_range(mut self, q_min: f64, q_max: f64) -> Self {
self.q_min = q_min;
self.q_max = q_max;
self
}
pub fn with_inclination(mut self, model: InclinationModel) -> Self {
self.inclination = model;
self
}
pub fn validate(&self) -> Result<()> {
let err = |msg: String| Err(StarfieldError::DataError(msg));
let range_ok = |lo: f64, hi: f64| lo > 0.0 && lo <= hi;
if !range_ok(self.a_min, self.a_max) {
return err(format!(
"invalid semi-major axis range [{}, {}] AU: need 0 < a_min <= a_max",
self.a_min, self.a_max
));
}
if !range_ok(self.q_min, self.q_max) {
return err(format!(
"invalid perihelion range [{}, {}] AU: need 0 < q_min <= q_max",
self.q_min, self.q_max
));
}
if self.q_min >= self.a_max {
return err(format!(
"empty (a, q) wedge: q_min = {} AU >= a_max = {} AU, no draw can satisfy q <= a",
self.q_min, self.a_max
));
}
match self.inclination {
InclinationModel::Planar => {}
InclinationModel::HalfNormal { sigma_deg }
| InclinationModel::Rayleigh { sigma_deg } => {
let sigma_ok = sigma_deg >= 0.0;
if !sigma_ok {
return err(format!("inclination sigma must be >= 0, got {sigma_deg}"));
}
}
}
Ok(())
}
pub fn generate<R: Rng + ?Sized>(&self, rng: &mut R) -> Result<Vec<SyntheticOrbit>> {
self.validate()?;
let mut orbits = Vec::with_capacity(self.count);
for _ in 0..self.count {
let (a, e) = loop {
let a = uniform(rng, self.a_min, self.a_max);
let q = uniform(rng, self.q_min, self.q_max);
if q <= a {
break (a, 1.0 - q / a);
}
};
let i_deg = match self.inclination {
InclinationModel::Planar => 0.0,
InclinationModel::HalfNormal { sigma_deg } => {
sigma_deg * standard_normal(rng).abs()
}
InclinationModel::Rayleigh { sigma_deg } => sigma_deg * rayleigh_unit(rng),
};
orbits.push(SyntheticOrbit {
a,
e,
i_deg,
omega_big_deg: uniform(rng, 0.0, 360.0),
omega_deg: uniform(rng, 0.0, 360.0),
mean_anomaly_deg: uniform(rng, 0.0, 360.0),
});
}
Ok(orbits)
}
}
fn uniform<R: Rng + ?Sized>(rng: &mut R, lo: f64, hi: f64) -> f64 {
lo + rng.random::<f64>() * (hi - lo)
}
fn standard_normal<R: Rng + ?Sized>(rng: &mut R) -> f64 {
let u1: f64 = rng.random::<f64>().max(f64::MIN_POSITIVE);
let u2: f64 = rng.random();
(-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
}
fn rayleigh_unit<R: Rng + ?Sized>(rng: &mut R) -> f64 {
let u: f64 = (1.0 - rng.random::<f64>()).max(f64::MIN_POSITIVE);
(-2.0 * u.ln()).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constants::GM_SUN;
use crate::time::Timescale;
use rand::rngs::StdRng;
use rand::SeedableRng;
#[test]
fn test_deterministic_count() {
let mut rng = StdRng::seed_from_u64(42);
let pop = SyntheticOrbitPopulation::new()
.with_count(500)
.with_semi_major_axis_range(31.0, 60.0)
.with_perihelion_range(30.0, 59.0)
.generate(&mut rng)
.unwrap();
assert_eq!(pop.len(), 500);
}
#[test]
fn test_same_seed_reproduces() {
let config = SyntheticOrbitPopulation::scattered_disk().with_count(64);
let a = config.generate(&mut StdRng::seed_from_u64(7)).unwrap();
let b = config.generate(&mut StdRng::seed_from_u64(7)).unwrap();
assert_eq!(a, b);
}
#[test]
fn test_elements_within_configured_ranges() {
let mut rng = StdRng::seed_from_u64(42);
let config = SyntheticOrbitPopulation::scattered_disk().with_count(400);
let pop = config.generate(&mut rng).unwrap();
for orbit in &pop {
assert!(
orbit.a >= config.a_min && orbit.a < config.a_max,
"a = {} out of [{}, {})",
orbit.a,
config.a_min,
config.a_max
);
let q = orbit.perihelion();
assert!(
q >= config.q_min - 1e-9 && q < config.q_max,
"q = {} out of [{}, {})",
q,
config.q_min,
config.q_max
);
assert!(orbit.e >= 0.0 && orbit.e < 1.0, "e = {}", orbit.e);
assert!(orbit.i_deg >= 0.0, "i = {}", orbit.i_deg);
for angle in [orbit.omega_big_deg, orbit.omega_deg, orbit.mean_anomaly_deg] {
assert!((0.0..360.0).contains(&angle), "angle = {angle}");
}
}
}
#[test]
fn test_half_normal_inclination_mean() {
let mut rng = StdRng::seed_from_u64(123);
let pop = SyntheticOrbitPopulation::scattered_disk()
.with_count(10_000)
.generate(&mut rng)
.unwrap();
let expected = 15.0 * (2.0 / std::f64::consts::PI).sqrt();
let actual: f64 = pop.iter().map(|o| o.i_deg).sum::<f64>() / pop.len() as f64;
let relative_error = (actual - expected).abs() / expected;
assert!(
relative_error < 0.05,
"mean i = {actual:.3} vs expected {expected:.3}"
);
}
#[test]
fn test_rayleigh_inclination_mean() {
let mut rng = StdRng::seed_from_u64(456);
let pop = SyntheticOrbitPopulation::new()
.with_count(10_000)
.with_inclination(InclinationModel::Rayleigh { sigma_deg: 12.0 })
.generate(&mut rng)
.unwrap();
let expected = 12.0 * (std::f64::consts::PI / 2.0).sqrt();
let actual: f64 = pop.iter().map(|o| o.i_deg).sum::<f64>() / pop.len() as f64;
let relative_error = (actual - expected).abs() / expected;
assert!(
relative_error < 0.05,
"mean i = {actual:.3} vs expected {expected:.3}"
);
}
#[test]
fn test_planar_inclination() {
let mut rng = StdRng::seed_from_u64(9);
let pop = SyntheticOrbitPopulation::new()
.with_count(20)
.with_inclination(InclinationModel::Planar)
.generate(&mut rng)
.unwrap();
assert!(pop.iter().all(|o| o.i_deg == 0.0));
}
#[test]
fn test_invalid_configs_rejected() {
let mut rng = StdRng::seed_from_u64(1);
assert!(SyntheticOrbitPopulation::new()
.with_semi_major_axis_range(550.0, 150.0)
.generate(&mut rng)
.is_err());
assert!(SyntheticOrbitPopulation::new()
.with_perihelion_range(50.0, 30.0)
.generate(&mut rng)
.is_err());
assert!(SyntheticOrbitPopulation::new()
.with_semi_major_axis_range(100.0, 150.0)
.with_perihelion_range(150.0, 200.0)
.generate(&mut rng)
.is_err());
assert!(SyntheticOrbitPopulation::new()
.with_inclination(InclinationModel::HalfNormal { sigma_deg: -1.0 })
.generate(&mut rng)
.is_err());
}
#[test]
fn test_derived_quantities() {
let orbit = SyntheticOrbit {
a: 500.0,
e: 0.9,
i_deg: 10.0,
omega_big_deg: 300.0,
omega_deg: 100.0,
mean_anomaly_deg: 0.0,
};
assert!((orbit.perihelion() - 50.0).abs() < 1e-12);
assert!((orbit.aphelion() - 950.0).abs() < 1e-12);
assert!((orbit.longitude_of_perihelion_deg() - 40.0).abs() < 1e-12);
}
#[test]
fn test_to_kepler_orbit_distance_in_radial_bounds() {
let ts = Timescale::default();
let epoch = ts.tt_jd(2_451_545.0, None);
let mut rng = StdRng::seed_from_u64(99);
let pop = SyntheticOrbitPopulation::scattered_disk()
.with_count(10)
.generate(&mut rng)
.unwrap();
for (n, orbit) in pop.iter().enumerate() {
let kepler = orbit.to_kepler_orbit(&epoch, GM_SUN, Some(&format!("synth-{n}")));
let r = kepler.at(&epoch).position.norm();
let (q, big_q) = (orbit.perihelion(), orbit.aphelion());
assert!(
r >= q * (1.0 - 1e-9) && r <= big_q * (1.0 + 1e-9),
"r = {r} outside [{q}, {big_q}]"
);
}
}
}