use std::{collections::HashMap, fmt};
use ahash::RandomState;
use rand::Rng;
use crate::{
constants::Observations, GaussResult, IODParams, ObjectNumber, ObservationIOD, Outfit,
OutfitError, TrajectorySet,
};
use std::time::{Duration, Instant};
#[cfg(feature = "progress")]
use super::progress_bar::IterTimer;
#[cfg(feature = "progress")]
use indicatif::{ProgressBar, ProgressStyle};
#[cfg(feature = "parallel")]
use rayon::prelude::*;
#[cfg(feature = "parallel")]
use std::{
hash::{Hash, Hasher},
mem,
};
pub type FullOrbitResult =
HashMap<ObjectNumber, Result<(GaussResult, f64), OutfitError>, RandomState>;
pub fn gauss_result_for<'a>(
all: &'a FullOrbitResult,
key: &ObjectNumber,
) -> Result<Option<(&'a GaussResult, f64)>, &'a OutfitError> {
match all.get(key) {
None => Ok(None),
Some(Err(e)) => Err(e),
Some(Ok((g, rms))) => Ok(Some((g, *rms))),
}
}
pub fn take_gauss_result(
all: &mut FullOrbitResult,
key: &ObjectNumber,
) -> Result<Option<(GaussResult, f64)>, OutfitError> {
match all.remove(key) {
None => Ok(None),
Some(Err(e)) => Err(e),
Some(Ok((g, rms))) => Ok(Some((g, rms))),
}
}
#[derive(Debug, Clone, Copy)]
pub struct ObsCountStats {
pub min: usize,
pub p25: usize,
pub median: usize,
pub p95: usize,
pub max: usize,
}
impl fmt::Display for ObsCountStats {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if f.alternate() {
writeln!(f, "Observation count per trajectory — summary")?;
writeln!(f, "-----------------------------------------")?;
writeln!(f, "min : {}", self.min)?;
writeln!(f, "p25 : {}", self.p25)?;
writeln!(f, "median : {}", self.median)?;
writeln!(f, "p95 : {}", self.p95)?;
write!(f, "max : {}", self.max)
} else {
write!(
f,
"min={}, p25={}, median={}, p95={}, max={}",
self.min, self.p25, self.median, self.p95, self.max
)
}
}
}
struct CancelCfg<F> {
interval: Duration,
should_cancel: F,
}
trait ProgressSink {
fn start(&mut self, _total: u64) {}
fn on_iter(&mut self) {}
fn inc(&mut self) {}
fn on_interrupt(&mut self) {}
fn finish(&mut self) {}
}
impl ProgressSink for () {}
impl<T: ProgressSink + ?Sized> ProgressSink for &mut T {
#[inline]
fn start(&mut self, total: u64) {
(**self).start(total)
}
#[inline]
fn on_iter(&mut self) {
(**self).on_iter()
}
#[inline]
fn inc(&mut self) {
(**self).inc()
}
#[inline]
fn on_interrupt(&mut self) {
(**self).on_interrupt()
}
#[inline]
fn finish(&mut self) {
(**self).finish()
}
}
#[cfg(feature = "progress")]
type ProgressImpl = IndicatifProgress;
#[cfg(not(feature = "progress"))]
type ProgressImpl = ();
fn estimate_all_orbits_core<F, P>(
set: &mut TrajectorySet,
state: &Outfit,
rng: &mut impl Rng,
params: &IODParams,
mut cancel: Option<CancelCfg<F>>,
mut progress: P,
) -> FullOrbitResult
where
F: FnMut() -> bool,
P: ProgressSink,
{
let total = set.len() as u64;
progress.start(total.max(1));
let mut results: FullOrbitResult = HashMap::default();
let mut last_poll = Instant::now();
for (obj, observations) in set.iter_mut() {
if let Some(CancelCfg {
interval,
should_cancel,
}) = cancel.as_mut()
{
if last_poll.elapsed() >= *interval {
if should_cancel() {
progress.on_interrupt();
break;
}
last_poll = Instant::now();
}
}
progress.on_iter();
let res = observations.estimate_best_orbit(state, &state.error_model, rng, params);
results.insert(obj.clone(), res);
progress.inc();
}
progress.finish();
results
}
#[cfg(feature = "progress")]
mod progress_impl {
use super::IterTimer;
use super::ProgressSink;
use crate::trajectories::progress_bar::fmt_dur;
pub(super) struct IndicatifProgress {
pb: super::ProgressBar,
it_timer: IterTimer,
}
impl Default for IndicatifProgress {
fn default() -> Self {
let pb = super::ProgressBar::new(1);
Self {
pb,
it_timer: IterTimer::new(0.2),
}
}
}
impl ProgressSink for IndicatifProgress {
fn start(&mut self, total: u64) {
self.pb.set_length(total.max(1));
self.pb.set_style(
super::ProgressStyle::with_template(
"{bar:40.cyan/blue} {pos}/{len} ({percent:>3}%) \
| {per_sec} | ETA {eta_precise} | {msg}",
)
.expect("indicatif template"),
);
self.pb
.enable_steady_tick(super::Duration::from_millis(200));
}
fn on_iter(&mut self) {
let last = self.it_timer.tick();
let avg = self.it_timer.avg();
self.pb
.set_message(format!("last: {}, avg: {}", fmt_dur(last), fmt_dur(avg)));
}
fn inc(&mut self) {
self.pb.inc(1);
}
fn on_interrupt(&mut self) {
self.pb.set_message("Interrupted");
}
fn finish(&mut self) {
self.pb.disable_steady_tick();
self.pb.finish_and_clear();
}
}
}
#[cfg(feature = "progress")]
use progress_impl::IndicatifProgress;
#[cfg(feature = "parallel")]
#[inline]
fn splitmix64(mut x: u64) -> u64 {
x = x.wrapping_add(0x9E3779B97F4A7C15);
let mut z = x;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^ (z >> 31)
}
#[cfg(feature = "parallel")]
#[inline]
fn seed_for_object(base: u64, obj: &ObjectNumber) -> u64 {
let mut h = ahash::AHasher::default();
obj.hash(&mut h);
let obj_h = h.finish();
splitmix64(base ^ obj_h)
}
pub trait TrajectoryFit {
fn estimate_all_orbits(
&mut self,
state: &Outfit,
rng: &mut impl Rng,
params: &IODParams,
) -> FullOrbitResult;
fn total_observations(&self) -> usize;
fn obs_count_stats(&self) -> Option<ObsCountStats>;
fn number_of_trajectories(&self) -> usize;
fn estimate_all_orbits_with_cancel<F>(
&mut self,
state: &Outfit,
rng: &mut impl Rng,
params: &IODParams,
should_cancel: F,
) -> FullOrbitResult
where
F: FnMut() -> bool;
#[cfg(feature = "parallel")]
fn estimate_all_orbits_in_batches_parallel(
&mut self,
state: &Outfit,
rng: &mut impl rand::Rng,
params: &IODParams,
) -> FullOrbitResult;
}
impl TrajectoryFit for TrajectorySet {
fn estimate_all_orbits(
&mut self,
state: &Outfit,
rng: &mut impl Rng,
params: &IODParams,
) -> FullOrbitResult {
estimate_all_orbits_core(
self,
state,
rng,
params,
None::<CancelCfg<fn() -> bool>>,
ProgressImpl::default(),
)
}
fn estimate_all_orbits_with_cancel<F>(
&mut self,
state: &Outfit,
rng: &mut impl Rng,
params: &IODParams,
mut should_cancel: F,
) -> FullOrbitResult
where
F: FnMut() -> bool,
{
let cancel = CancelCfg {
interval: Duration::from_millis(20),
should_cancel: &mut should_cancel,
};
estimate_all_orbits_core(
self,
state,
rng,
params,
Some(cancel),
ProgressImpl::default(),
)
}
#[cfg(feature = "parallel")]
fn estimate_all_orbits_in_batches_parallel(
&mut self,
state: &Outfit,
rng: &mut impl rand::Rng,
params: &IODParams,
) -> FullOrbitResult {
let base_seed: u64 = rng.random();
let mut old: TrajectorySet = mem::take(self);
let mut entries: Vec<(ObjectNumber, Observations)> = old.drain().collect();
let total_items = entries.len() as u64;
let mut batches: Vec<Vec<(ObjectNumber, Observations)>> =
Vec::with_capacity(entries.len().div_ceil(params.batch_size.max(1)));
while !entries.is_empty() {
let take_n = entries.len().min(params.batch_size);
batches.push(entries.drain(..take_n).collect());
}
#[cfg(feature = "progress")]
let pb = {
use indicatif::{ProgressBar, ProgressStyle};
let pb = ProgressBar::new(total_items.max(1));
pb.set_style(
ProgressStyle::with_template(
"{bar:40.cyan/blue} {pos}/{len} ({percent:>3}%) \
| {per_sec} | ETA {eta_precise} | parallel batches",
)
.expect("indicatif template"),
);
pb.enable_steady_tick(std::time::Duration::from_millis(200));
pb
};
#[allow(clippy::type_complexity)]
let mut per_batch: Vec<
Vec<(
ObjectNumber,
Result<(GaussResult, f64), OutfitError>,
Observations,
)>,
> = batches
.into_par_iter()
.map(|mut batch| {
let mut out: Vec<(
ObjectNumber,
Result<(GaussResult, f64), OutfitError>,
Observations,
)> = Vec::with_capacity(batch.len());
for (obj, mut obs) in batch.drain(..) {
use rand::SeedableRng;
let local_seed = seed_for_object(base_seed, &obj);
let mut local_rng = rand::rngs::StdRng::seed_from_u64(local_seed);
let res =
obs.estimate_best_orbit(state, &state.error_model, &mut local_rng, params);
#[cfg(feature = "progress")]
pb.inc(1);
out.push((obj, res, obs));
}
out
})
.collect();
#[cfg(feature = "progress")]
{
pb.disable_steady_tick();
pb.finish_and_clear();
}
let mut results: FullOrbitResult = HashMap::with_hasher(ahash::RandomState::new());
for batch in per_batch.drain(..) {
for (obj, res, obs) in batch {
self.insert(obj.clone(), obs);
results.insert(obj, res);
}
}
results
}
#[inline]
fn total_observations(&self) -> usize {
self.values().map(|obs: &Observations| obs.len()).sum()
}
#[inline]
fn number_of_trajectories(&self) -> usize {
self.len()
}
fn obs_count_stats(&self) -> Option<ObsCountStats> {
let mut counts: Vec<usize> = self.values().map(|obs| obs.len()).collect();
if counts.is_empty() {
return None;
}
counts.sort_unstable();
#[inline]
fn q_index(n: usize, q: f64) -> usize {
let pos = q * (n as f64 - 1.0);
let idx = pos.round() as isize;
idx.clamp(0, (n as isize) - 1) as usize
}
let n = counts.len();
let min = counts[0];
let max = counts[n - 1];
let p25 = counts[q_index(n, 0.25)];
let median = counts[q_index(n, 0.50)];
let p95 = counts[q_index(n, 0.95)];
Some(ObsCountStats {
min,
p25,
median,
p95,
max,
})
}
}
#[cfg(test)]
#[cfg(feature = "jpl-download")]
mod tests_estimate_all_orbits {
use crate::{
observations::Observation, unit_test_global::OUTFIT_HORIZON_TEST, KeplerianElements,
};
use super::*;
use approx::assert_relative_eq;
use rand::SeedableRng;
use smallvec::SmallVec;
use std::{
f64::consts::PI,
sync::atomic::{AtomicUsize, Ordering},
};
fn make_set(n: usize) -> TrajectorySet {
let mut set: TrajectorySet = std::collections::HashMap::with_hasher(RandomState::new());
for i in 0..n {
let key = ObjectNumber::Int(i as u32);
let obs: Observations = Default::default();
set.insert(key, obs);
}
set
}
fn dummy_env() -> (Outfit, IODParams) {
let env = OUTFIT_HORIZON_TEST.0.clone();
let params = IODParams::builder()
.n_noise_realizations(10)
.noise_scale(1.0)
.max_obs_for_triplets(12)
.max_triplets(30)
.build()
.unwrap();
(env, params)
}
#[test]
fn core_cancel_before_any_work() {
let mut set = make_set(5);
let (env, params) = dummy_env();
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let mut cancel_called = 0usize;
let mut should_cancel = || {
cancel_called += 1;
true
};
let cancel = CancelCfg {
interval: Duration::from_millis(0),
should_cancel: &mut should_cancel,
};
let results = estimate_all_orbits_core(&mut set, &env, &mut rng, ¶ms, Some(cancel), ());
assert!(results.is_empty(), "No object should have been processed");
assert!(
cancel_called >= 1,
"Cancellation should have been polled at least once"
);
}
#[test]
fn core_cancel_after_one_object() {
let mut set = make_set(2);
let (env, params) = dummy_env();
let mut rng = rand::rngs::StdRng::seed_from_u64(123);
let polls = AtomicUsize::new(0);
let mut should_cancel = || {
let c = polls.fetch_add(1, Ordering::Relaxed);
c >= 1
};
let cancel = CancelCfg {
interval: Duration::from_millis(0), should_cancel: &mut should_cancel,
};
let results = estimate_all_orbits_core(&mut set, &env, &mut rng, ¶ms, Some(cancel), ());
assert_eq!(
results.len(),
1,
"Exactly one object should have been processed before cancel"
);
}
#[derive(Default)]
struct MockProgress {
started_with: Option<u64>,
it_calls: usize,
inc_calls: usize,
interrupted: bool,
finished: bool,
}
impl ProgressSink for MockProgress {
fn start(&mut self, total: u64) {
self.started_with = Some(total);
}
fn on_iter(&mut self) {
self.it_calls += 1;
}
fn inc(&mut self) {
self.inc_calls += 1;
}
fn on_interrupt(&mut self) {
self.interrupted = true;
}
fn finish(&mut self) {
self.finished = true;
}
}
#[test]
fn progress_calls_when_cancelled_immediately() {
let mut set = make_set(3);
let (env, params) = dummy_env();
let mut rng = rand::rngs::StdRng::seed_from_u64(7);
let mut should_cancel = || true;
let cancel = CancelCfg {
interval: Duration::from_millis(0),
should_cancel: &mut should_cancel,
};
let mut mock = MockProgress::default();
let results =
estimate_all_orbits_core(&mut set, &env, &mut rng, ¶ms, Some(cancel), &mut mock);
assert!(results.is_empty());
assert_eq!(mock.started_with, Some(3));
assert!(mock.interrupted, "on_interrupt() must be called");
assert!(mock.finished, "finish() must be called");
assert_eq!(mock.it_calls, 0);
assert_eq!(mock.inc_calls, 0);
}
#[inline]
fn angle_abs_diff(a: f64, b: f64) -> f64 {
let tau = 2.0 * PI;
let mut d = (a - b) % tau;
if d > PI {
d -= tau;
}
if d < -PI {
d += tau;
}
d.abs()
}
pub fn assert_keplerian_approx_eq(
got: &KeplerianElements,
exp: &KeplerianElements,
abs_eps: f64,
rel_eps: f64,
) {
assert_relative_eq!(
got.reference_epoch,
exp.reference_epoch,
epsilon = abs_eps,
max_relative = rel_eps
);
assert_relative_eq!(
got.semi_major_axis,
exp.semi_major_axis,
epsilon = abs_eps,
max_relative = rel_eps
);
assert_relative_eq!(
got.eccentricity,
exp.eccentricity,
epsilon = abs_eps,
max_relative = rel_eps
);
for (name, g, e) in [
("inclination", got.inclination, exp.inclination),
(
"ascending_node_longitude",
got.ascending_node_longitude,
exp.ascending_node_longitude,
),
(
"periapsis_argument",
got.periapsis_argument,
exp.periapsis_argument,
),
("mean_anomaly", got.mean_anomaly, exp.mean_anomaly),
] {
let diff = angle_abs_diff(g, e);
let tol = abs_eps.max(rel_eps * e.abs());
assert!(
diff <= tol,
"Angle {name:?} differs too much: |Δ| = {diff:.6e} > tol {tol:.6e} (got={g:.15}, exp={e:.15})"
);
}
}
#[test]
fn public_no_progress_runs_all_objects() {
let mut set = OUTFIT_HORIZON_TEST.1.clone();
let (env, params) = dummy_env();
let mut rng = rand::rngs::StdRng::seed_from_u64(777);
use super::TrajectoryFit;
let results = set.estimate_all_orbits(&env, &mut rng, ¶ms);
let string_id = "K09R05F";
let orbit = gauss_result_for(&results, &string_id.into())
.unwrap()
.unwrap()
.0
.as_inner()
.as_keplerian()
.unwrap();
let expected = KeplerianElements {
reference_epoch: 57049.25533417104,
semi_major_axis: 1.8017448718161189,
eccentricity: 0.283572382702194,
inclination: 0.2026747553253312,
ascending_node_longitude: 0.0079836299943183,
periapsis_argument: 1.245049339166438,
mean_anomaly: 0.4406946018418537,
};
assert_keplerian_approx_eq(orbit, &expected, 1e-6, 1e-6);
}
#[test]
fn public_with_cancel_returns_partial() {
let mut set = OUTFIT_HORIZON_TEST.1.clone();
let (env, params) = dummy_env();
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
use super::TrajectoryFit;
let results = set.estimate_all_orbits_with_cancel(&env, &mut rng, ¶ms, || true);
assert!(
results.len() <= 50,
"Result map cannot exceed the number of objects"
);
assert!(
!results.is_empty(),
"Depending on timing, a few items may be processed before first poll"
);
}
#[test]
fn gauss_accessors_err_and_missing() {
let mut all: FullOrbitResult = HashMap::with_hasher(RandomState::new());
let k1 = ObjectNumber::Int(1);
let k2 = ObjectNumber::Int(2);
all.insert(
k1.clone(),
Err(OutfitError::InvalidIODParameter("test".into())),
);
assert!(matches!(gauss_result_for(&all, &k2), Ok(None)));
match gauss_result_for(&all, &k1) {
Err(e) => {
let _ = format!("{e}");
}
other => panic!("expected Err(&OutfitError), got {other:?}"),
}
assert!(matches!(take_gauss_result(&mut all, &k2), Ok(None)));
match take_gauss_result(&mut all, &k1) {
Err(e) => {
let _ = format!("{e}");
}
other => panic!("expected Err(OutfitError), got {other:?}"),
}
}
#[test]
fn obs_count_stats_basic() {
use std::collections::HashMap;
#[inline]
fn dummy_observation() -> Observation {
assert_is_copy::<Observation>();
unsafe { std::mem::MaybeUninit::<Observation>::zeroed().assume_init() }
}
#[inline(always)]
fn assert_is_copy<T: Copy>() {}
let set = make_set(0);
assert!(set.obs_count_stats().is_none(), "Empty set → None");
let mut set: TrajectorySet = HashMap::with_hasher(RandomState::new());
let mut push_n = |id: u32, n: usize| {
let mut v: Observations = SmallVec::with_capacity(n);
for _ in 0..n {
v.push(dummy_observation());
}
set.insert(ObjectNumber::Int(id), v);
};
push_n(1, 2);
push_n(2, 4);
push_n(3, 8);
push_n(4, 16);
push_n(5, 16);
let stats = set.obs_count_stats().expect("non-empty");
assert_eq!(stats.min, 2);
assert_eq!(stats.max, 16);
assert_eq!(stats.median, 8);
assert_eq!(stats.p25, 4);
assert_eq!(stats.p95, 16);
}
#[cfg(test)]
#[cfg(feature = "parallel")]
mod tests_estimate_orbit_parallel_batches {
use super::*;
use ahash::RandomState;
use rand::SeedableRng;
fn make_set(n: usize) -> TrajectorySet {
let mut set: TrajectorySet = std::collections::HashMap::with_hasher(RandomState::new());
for i in 0..n {
set.insert(ObjectNumber::Int(i as u32), Default::default());
}
set
}
#[inline]
fn total_obs(set: &TrajectorySet) -> usize {
set.values().map(|v: &Observations| v.len()).sum()
}
#[test]
fn parallel_batches_empty_set_is_empty() {
let mut set = make_set(0);
let env = dummy_env().0;
let params = IODParams::builder().batch_size(1024).build().unwrap();
let mut rng = rand::rngs::StdRng::seed_from_u64(1);
let results = set.estimate_all_orbits_in_batches_parallel(&env, &mut rng, ¶ms);
assert!(results.is_empty(), "Empty input → empty results");
assert_eq!(set.len(), 0, "Set remains empty");
}
#[test]
fn parallel_batches_size_edges_cover_all_objects() {
for &batch_size in &[1usize, 10_000usize] {
let mut set = make_set(7);
let env = dummy_env().0;
let params = IODParams::builder().batch_size(batch_size).build().unwrap();
let before_n = set.len();
let before_tot = total_obs(&set);
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let results = set.estimate_all_orbits_in_batches_parallel(&env, &mut rng, ¶ms);
assert_eq!(results.len(), before_n, "Exactly one entry per object");
assert_eq!(set.len(), before_n, "All objects reinserted in set");
assert_eq!(
total_obs(&set),
before_tot,
"Total number of observations is preserved (reorder/calibration only)"
);
}
}
#[test]
fn parallel_batches_deterministic_across_runs_with_same_seed() {
let build_set = || make_set(5);
let env = dummy_env().0;
let params = IODParams::builder().batch_size(2).build().unwrap();
let key = ObjectNumber::Int(2);
let run_once = |seed: u64| {
let mut set = build_set();
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let results = set.estimate_all_orbits_in_batches_parallel(&env, &mut rng, ¶ms);
match results.get(&key) {
None => "None".to_string(),
Some(Ok((_g, rms))) => format!("Ok rms={rms:.12e}"),
Some(Err(e)) => format!("Err: {e}"),
}
};
let a = run_once(0xDEADBEEF);
let b = run_once(0xDEADBEEF);
assert_eq!(a, b, "Same seed/input → identical outcome formatting");
}
mod with_ephem {
use super::*;
use approx::assert_relative_eq;
use crate::unit_test_global::OUTFIT_HORIZON_TEST;
#[test]
fn parallel_batches_return_orbit() {
let mut set = OUTFIT_HORIZON_TEST.1.clone();
let (env, params) = {
let env = OUTFIT_HORIZON_TEST.0.clone();
let params = IODParams::builder()
.n_noise_realizations(10)
.noise_scale(1.0)
.max_obs_for_triplets(12)
.max_triplets(30)
.batch_size(1)
.build()
.unwrap();
(env, params)
};
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let results = set.estimate_all_orbits_in_batches_parallel(&env, &mut rng, ¶ms);
let string_id = "K09R05F";
let orbit = gauss_result_for(&results, &string_id.into());
assert!(orbit.is_ok(), "Result entry should be Ok");
}
#[test]
fn parallel_batches_results_independent_of_batch_size() {
let mut set1 = OUTFIT_HORIZON_TEST.1.clone();
let mut set2 = OUTFIT_HORIZON_TEST.1.clone();
let (env, params) = {
let env = OUTFIT_HORIZON_TEST.0.clone();
let params = IODParams::builder()
.n_noise_realizations(10)
.noise_scale(1.0)
.max_obs_for_triplets(12)
.max_triplets(30)
.batch_size(64)
.build()
.unwrap();
(env, params)
};
let seed = 0xABCDEF0123456789;
let mut rng1 = rand::rngs::StdRng::seed_from_u64(seed);
let mut rng2 = rand::rngs::StdRng::seed_from_u64(seed);
let res1 = set1.estimate_all_orbits_in_batches_parallel(&env, &mut rng1, ¶ms);
let params2 = IODParams {
batch_size: 4096,
..params.clone()
};
let res2 = set2.estimate_all_orbits_in_batches_parallel(&env, &mut rng2, ¶ms2);
let key = "K09R05F".into();
let k1 = gauss_result_for(&res1, &key)
.unwrap()
.unwrap()
.0
.as_inner()
.as_keplerian()
.unwrap();
let k2 = gauss_result_for(&res2, &key)
.unwrap()
.unwrap()
.0
.as_inner()
.as_keplerian()
.unwrap();
assert_relative_eq!(k1.reference_epoch, k2.reference_epoch, epsilon = 0.0);
assert_relative_eq!(k1.semi_major_axis, k2.semi_major_axis, epsilon = 0.0);
assert_relative_eq!(k1.eccentricity, k2.eccentricity, epsilon = 0.0);
assert_relative_eq!(k1.inclination, k2.inclination, epsilon = 0.0);
assert_relative_eq!(
k1.ascending_node_longitude,
k2.ascending_node_longitude,
epsilon = 0.0
);
assert_relative_eq!(k1.periapsis_argument, k2.periapsis_argument, epsilon = 0.0);
assert_relative_eq!(k1.mean_anomaly, k2.mean_anomaly, epsilon = 0.0);
}
}
}
}