use crate::data::sbdb::{SbdbClient, SbdbLookupResponse};
use crate::{Result, StarfieldError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Element {
SemiMajorAxis,
Eccentricity,
Inclination,
ArgPerihelion,
AscendingNode,
AbsoluteMagnitude,
}
impl Element {
pub fn label(&self) -> &'static str {
match self {
Element::SemiMajorAxis => "a [AU]",
Element::Eccentricity => "e",
Element::Inclination => "i [deg]",
Element::ArgPerihelion => "omega [deg]",
Element::AscendingNode => "Omega [deg]",
Element::AbsoluteMagnitude => "H [mag]",
}
}
pub fn is_angle(&self) -> bool {
matches!(
self,
Element::Inclination | Element::ArgPerihelion | Element::AscendingNode
)
}
}
#[derive(Debug, Clone, Default)]
pub struct ElementSnapshot {
pub name: String,
pub designation: String,
pub a: Option<f64>,
pub e: Option<f64>,
pub i_deg: Option<f64>,
pub omega_deg: Option<f64>,
pub omega_big_deg: Option<f64>,
pub h_mag: Option<f64>,
}
#[derive(Debug, Clone, Default)]
pub struct LiveElements {
pub a: Option<f64>,
pub e: Option<f64>,
pub i_deg: Option<f64>,
pub omega_deg: Option<f64>,
pub omega_big_deg: Option<f64>,
pub h_mag: Option<f64>,
pub epoch_jd: Option<f64>,
pub orbit_id: Option<String>,
}
impl LiveElements {
pub fn from_lookup(response: &SbdbLookupResponse) -> Result<Self> {
let orbit = response.orbit.as_ref().ok_or_else(|| {
StarfieldError::DataError(format!(
"SBDB returned no orbit for {:?}",
response.object.designation
))
})?;
Ok(LiveElements {
a: orbit.semi_major_axis,
e: orbit.eccentricity,
i_deg: orbit.inclination,
omega_deg: orbit.arg_perihelion,
omega_big_deg: orbit.long_asc_node,
h_mag: response.phys_par.as_ref().and_then(|p| p.abs_magnitude_h),
epoch_jd: orbit.epoch_jd,
orbit_id: orbit.orbit_id.clone(),
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct Tolerances {
pub a_frac: f64,
pub a_abs: f64,
pub e_abs: f64,
pub angle_deg: f64,
pub h_mag: f64,
}
impl Tolerances {
pub fn published_table() -> Self {
Tolerances {
a_frac: 0.01,
a_abs: 1.0,
e_abs: 0.01,
angle_deg: 0.5,
h_mag: 0.5,
}
}
pub fn full_precision() -> Self {
Tolerances {
a_frac: 0.005,
a_abs: 0.2,
e_abs: 0.005,
angle_deg: 0.1,
h_mag: 0.5,
}
}
fn for_element(&self, element: Element, snapshot_value: f64) -> f64 {
match element {
Element::SemiMajorAxis => (self.a_frac * snapshot_value.abs()).max(self.a_abs),
Element::Eccentricity => self.e_abs,
Element::Inclination | Element::ArgPerihelion | Element::AscendingNode => {
self.angle_deg
}
Element::AbsoluteMagnitude => self.h_mag,
}
}
}
#[derive(Debug, Clone)]
pub struct ElementDiff {
pub object: String,
pub element: Element,
pub snapshot: f64,
pub live: f64,
pub delta: f64,
pub tolerance: f64,
pub orbit_id: Option<String>,
}
impl std::fmt::Display for ElementDiff {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: {} snapshot {} vs live {} (delta {:+.4}, tolerance {:.4}{})",
self.object,
self.element.label(),
self.snapshot,
self.live,
self.delta,
self.tolerance,
match &self.orbit_id {
Some(id) => format!(", JPL soln {id}"),
None => String::new(),
}
)
}
}
pub fn angle_delta_deg(from: f64, to: f64) -> f64 {
let mut d = (to - from).rem_euclid(360.0);
if d > 180.0 {
d -= 360.0;
}
d
}
pub fn diff_elements(
snapshot: &ElementSnapshot,
live: &LiveElements,
tolerances: &Tolerances,
) -> Vec<ElementDiff> {
let pairs = [
(Element::SemiMajorAxis, snapshot.a, live.a),
(Element::Eccentricity, snapshot.e, live.e),
(Element::Inclination, snapshot.i_deg, live.i_deg),
(Element::ArgPerihelion, snapshot.omega_deg, live.omega_deg),
(
Element::AscendingNode,
snapshot.omega_big_deg,
live.omega_big_deg,
),
(Element::AbsoluteMagnitude, snapshot.h_mag, live.h_mag),
];
let mut diffs = Vec::new();
for (element, snapshot_value, live_value) in pairs {
let (Some(s), Some(l)) = (snapshot_value, live_value) else {
continue;
};
let delta = if element.is_angle() {
angle_delta_deg(s, l)
} else {
l - s
};
let tolerance = tolerances.for_element(element, s);
if delta.abs() > tolerance {
diffs.push(ElementDiff {
object: snapshot.name.clone(),
element,
snapshot: s,
live: l,
delta,
tolerance,
orbit_id: live.orbit_id.clone(),
});
}
}
diffs
}
pub fn diff_against_live(
client: &SbdbClient,
snapshots: &[ElementSnapshot],
tolerances: &Tolerances,
) -> Result<Vec<ElementDiff>> {
let mut diffs = Vec::new();
for snapshot in snapshots {
let response = client.lookup(&snapshot.designation)?;
let live = LiveElements::from_lookup(&response)?;
diffs.extend(diff_elements(snapshot, &live, tolerances));
}
Ok(diffs)
}
#[cfg(test)]
mod tests {
use super::*;
fn snapshot() -> ElementSnapshot {
ElementSnapshot {
name: "Testna".to_string(),
designation: "Testna".to_string(),
a: Some(500.0),
e: Some(0.85),
i_deg: Some(11.9),
omega_deg: Some(311.5),
omega_big_deg: Some(144.5),
h_mag: Some(1.6),
}
}
fn matching_live() -> LiveElements {
LiveElements {
a: Some(500.0),
e: Some(0.85),
i_deg: Some(11.9),
omega_deg: Some(311.5),
omega_big_deg: Some(144.5),
h_mag: Some(1.6),
..Default::default()
}
}
#[test]
fn test_identical_elements_no_diff() {
let diffs = diff_elements(
&snapshot(),
&matching_live(),
&Tolerances::published_table(),
);
assert!(diffs.is_empty(), "{diffs:?}");
}
#[test]
fn test_drift_within_tolerance_no_diff() {
let live = LiveElements {
a: Some(502.0),
e: Some(0.855),
i_deg: Some(11.9),
omega_deg: Some(311.8),
omega_big_deg: Some(144.5),
h_mag: Some(1.8),
..Default::default()
};
let diffs = diff_elements(&snapshot(), &live, &Tolerances::published_table());
assert!(diffs.is_empty(), "{diffs:?}");
}
#[test]
fn test_semi_major_axis_drift_flagged() {
let live = LiveElements {
a: Some(540.0), ..matching_live()
};
let diffs = diff_elements(&snapshot(), &live, &Tolerances::published_table());
assert_eq!(diffs.len(), 1);
let d = &diffs[0];
assert_eq!(d.element, Element::SemiMajorAxis);
assert_eq!(d.object, "Testna");
assert_eq!(d.snapshot, 500.0);
assert_eq!(d.live, 540.0);
assert!((d.delta - 40.0).abs() < 1e-12);
assert!((d.tolerance - 5.0).abs() < 1e-12); }
#[test]
fn test_multiple_elements_flagged_independently() {
let live = LiveElements {
e: Some(0.88), i_deg: Some(13.0), ..matching_live()
};
let diffs = diff_elements(&snapshot(), &live, &Tolerances::published_table());
let elements: Vec<Element> = diffs.iter().map(|d| d.element).collect();
assert_eq!(
elements,
vec![Element::Eccentricity, Element::Inclination],
"{diffs:?}"
);
}
#[test]
fn test_angle_delta_is_wrap_aware() {
assert!((angle_delta_deg(359.9, 0.3) - 0.4).abs() < 1e-9);
assert!((angle_delta_deg(0.3, 359.9) + 0.4).abs() < 1e-9);
let snap = ElementSnapshot {
omega_deg: Some(359.9),
..snapshot()
};
let live = LiveElements {
omega_deg: Some(0.3),
..matching_live()
};
let diffs = diff_elements(&snap, &live, &Tolerances::published_table());
assert!(
diffs.is_empty(),
"wrap-around must not be flagged: {diffs:?}"
);
}
#[test]
fn test_missing_elements_skipped() {
let snap = ElementSnapshot {
omega_deg: None,
omega_big_deg: None,
h_mag: None,
..snapshot()
};
let live = LiveElements {
omega_deg: Some(100.0), omega_big_deg: Some(10.0), h_mag: None,
..matching_live()
};
let diffs = diff_elements(&snap, &live, &Tolerances::published_table());
assert!(diffs.is_empty(), "{diffs:?}");
}
#[test]
fn test_full_precision_is_tighter() {
let live = LiveElements {
a: Some(503.5),
..matching_live()
};
assert!(diff_elements(&snapshot(), &live, &Tolerances::published_table()).is_empty());
let diffs = diff_elements(&snapshot(), &live, &Tolerances::full_precision());
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].element, Element::SemiMajorAxis);
}
#[test]
fn test_diff_display_names_object_and_element() {
let live = LiveElements {
a: Some(540.0),
orbit_id: Some("12".to_string()),
..matching_live()
};
let diffs = diff_elements(&snapshot(), &live, &Tolerances::published_table());
let msg = diffs[0].to_string();
assert!(msg.contains("Testna"), "{msg}");
assert!(msg.contains("a [AU]"), "{msg}");
assert!(msg.contains("540"), "{msg}");
assert!(msg.contains("JPL soln 12"), "{msg}");
}
#[test]
#[ignore]
fn test_diff_against_live_sedna() {
let snapshots = vec![ElementSnapshot {
name: "Sedna".to_string(),
designation: "Sedna".to_string(),
a: None,
e: None,
i_deg: Some(11.9),
omega_deg: Some(311.5),
omega_big_deg: Some(144.5),
h_mag: Some(1.6),
}];
let client = SbdbClient::new().unwrap();
let diffs = diff_against_live(&client, &snapshots, &Tolerances::published_table()).unwrap();
for d in &diffs {
println!("{d}");
assert!(
d.element == Element::ArgPerihelion || d.element == Element::AbsoluteMagnitude,
"unexpected drift: {d}"
);
}
}
}