use crate::coordinate::CoordinateState;
use crate::thrust::{ThrustArc, ThrustParams};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum PhaseFunction {
HG = 0,
HG1G2 = 1,
HG12 = 2,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SrpParams {
pub amrat: f64,
pub cr: f64,
pub amrat_variance: Option<f64>,
}
impl SrpParams {
pub(crate) fn from_ffi(amrat: f64, cr: f64, has_srp: u8, amrat_variance: f64) -> Option<Self> {
if has_srp == 0 {
return None;
}
Some(Self {
amrat,
cr,
amrat_variance: if amrat_variance.is_finite() && amrat_variance > 0.0 {
Some(amrat_variance)
} else {
None
},
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Orbit {
pub orbit_id: Option<String>,
pub object_id: Option<String>,
pub state: CoordinateState,
pub a1: f64,
pub a2: f64,
pub a3: f64,
pub ng_alpha: f64,
pub ng_r0: f64,
pub ng_m: f64,
pub ng_n: f64,
pub ng_k: f64,
pub non_grav_dt: Option<f64>,
pub non_grav_dt_variance: Option<f64>,
pub ng_covariance: Option<[[f64; 3]; 3]>,
pub phot_system: Option<PhaseFunction>,
pub h_mag: f64,
pub slope1: f64,
pub slope2: f64,
pub phot_covariance: Option<[[f64; 3]; 3]>,
pub thrust: Option<ThrustParams>,
pub srp: Option<SrpParams>,
pub wide_cross: Option<crate::WideCross>,
}
impl Orbit {
pub fn new(state: CoordinateState) -> Self {
Self {
orbit_id: None,
object_id: None,
state,
a1: 0.0,
a2: 0.0,
a3: 0.0,
ng_alpha: 0.0,
ng_r0: 0.0,
ng_m: 0.0,
ng_n: 0.0,
ng_k: 0.0,
non_grav_dt: None,
non_grav_dt_variance: None,
ng_covariance: None,
phot_system: None,
h_mag: f64::NAN,
slope1: 0.0,
slope2: 0.0,
phot_covariance: None,
thrust: None,
srp: None,
wide_cross: None,
}
}
pub fn with_orbit_id(mut self, id: impl Into<String>) -> Self {
self.orbit_id = Some(id.into());
self
}
pub fn with_object_id(mut self, id: impl Into<String>) -> Self {
self.object_id = Some(id.into());
self
}
pub fn with_nongrav(mut self, a1: f64, a2: f64, a3: f64) -> Self {
self.a1 = a1;
self.a2 = a2;
self.a3 = a3;
self
}
pub fn with_g_function(mut self, alpha: f64, r0: f64, m: f64, n: f64, k: f64) -> Self {
self.ng_alpha = alpha;
self.ng_r0 = r0;
self.ng_m = m;
self.ng_n = n;
self.ng_k = k;
self
}
pub fn with_non_grav_dt(mut self, dt: Option<f64>) -> Self {
self.non_grav_dt = dt;
self
}
pub fn with_non_grav_dt_variance(mut self, v: Option<f64>) -> Self {
self.non_grav_dt_variance = v;
self
}
pub fn with_nongrav_covariance(mut self, covariance: Option<[[f64; 3]; 3]>) -> Self {
self.ng_covariance = covariance;
self
}
pub fn with_photometry(
mut self,
phot_system: PhaseFunction,
h: f64,
slope1: f64,
slope2: f64,
) -> Self {
self.phot_system = Some(phot_system);
self.h_mag = h;
self.slope1 = slope1;
self.slope2 = slope2;
self
}
pub fn with_photometry_covariance(mut self, covariance: Option<[[f64; 3]; 3]>) -> Self {
self.phot_covariance = covariance;
self
}
pub fn with_hg(self, h: f64, g: f64) -> Self {
self.with_photometry(PhaseFunction::HG, h, g, 0.0)
}
pub fn with_thrust(mut self, thrust: Option<ThrustParams>) -> Self {
self.thrust = thrust;
self
}
pub fn with_srp(mut self, amrat: f64, cr: f64) -> Self {
let amrat_variance = self.srp.and_then(|s| s.amrat_variance);
self.srp = Some(SrpParams {
amrat,
cr,
amrat_variance,
});
self
}
pub fn with_srp_amrat_variance(mut self, v: Option<f64>) -> Self {
if let Some(srp) = self.srp.as_mut() {
srp.amrat_variance = v;
}
self
}
pub(crate) fn to_ffi_with_keep(
&self,
) -> crate::error::Result<(empyrean_sys::EmpyreanOrbit, OrbitFfiKeep)> {
use std::ffi::CString;
if let Some(cov) = self.phot_covariance {
validate_photometry_covariance_symmetry(&cov)?;
}
let (phase_int, h, s1, s2) = match self.phot_system {
Some(pf) => {
if self.h_mag == 0.0 {
return Err(crate::error::Error::invalid_input(
"photometry was attached with h_mag = 0.0, which the C ABI reads as \
'no absolute magnitude supplied' (it is the memset(0) value). No \
solar-system minor body has H = 0 — supply the real absolute \
magnitude, or attach no photometry at all.",
));
}
(pf as i32, self.h_mag, self.slope1, self.slope2)
}
None => (-1, f64::NAN, 0.0, 0.0),
};
let orbit_id_cstr =
CString::new(self.orbit_id.as_deref().unwrap_or("")).unwrap_or_default();
let object_id_cstr =
CString::new(self.object_id.as_deref().unwrap_or("")).unwrap_or_default();
let orbit_id_ptr = orbit_id_cstr.as_ptr();
let object_id_ptr = object_id_cstr.as_ptr();
let thrust_arcs: Vec<empyrean_sys::EmpyreanThrustArc> = self
.thrust
.as_ref()
.map(|tp| tp.arcs.iter().map(ThrustArc::to_ffi).collect())
.unwrap_or_default();
let dv_corrections: Vec<[f64; 3]> = self
.thrust
.as_ref()
.map(|tp| tp.dv_corrections.clone())
.unwrap_or_default();
let correction_covariances: Vec<[[f64; 3]; 3]> = self
.thrust
.as_ref()
.map(|tp| tp.correction_covariances.clone())
.unwrap_or_default();
let thrust_arcs_ptr = if thrust_arcs.is_empty() {
std::ptr::null()
} else {
thrust_arcs.as_ptr()
};
let dv_corrections_ptr = if dv_corrections.is_empty() {
std::ptr::null()
} else {
dv_corrections.as_ptr()
};
let correction_covariances_ptr = if correction_covariances.is_empty() {
std::ptr::null()
} else {
correction_covariances.as_ptr()
};
let (state_param_cross, param_pair_cross) = match self.wide_cross.as_ref() {
Some(w) => w.to_ffi_arrays(),
None => (Vec::new(), Vec::new()),
};
let state_param_cross_ptr = if state_param_cross.is_empty() {
std::ptr::null()
} else {
state_param_cross.as_ptr()
};
let param_pair_cross_ptr = if param_pair_cross.is_empty() {
std::ptr::null()
} else {
param_pair_cross.as_ptr()
};
let ffi = empyrean_sys::EmpyreanOrbit {
state: self.state.to_ffi()?,
orbit_id: orbit_id_ptr,
object_id: object_id_ptr,
a1: self.a1,
a2: self.a2,
a3: self.a3,
ng_alpha: self.ng_alpha,
ng_r0: self.ng_r0,
ng_m: self.ng_m,
ng_n: self.ng_n,
ng_k: self.ng_k,
non_grav_dt: self.non_grav_dt.unwrap_or(f64::NAN),
non_grav_dt_variance: self.non_grav_dt_variance.unwrap_or(f64::NAN),
has_non_grav_covariance: u8::from(self.ng_covariance.is_some()),
non_grav_covariance: self.ng_covariance.unwrap_or([[0.0; 3]; 3]),
phot_system: phase_int,
h_mag: h,
slope1: s1,
slope2: s2,
thrust_arcs: thrust_arcs_ptr,
n_thrust_arcs: thrust_arcs.len(),
dv_corrections: dv_corrections_ptr,
n_dv_corrections: dv_corrections.len(),
correction_covariances: correction_covariances_ptr,
n_correction_covariances: correction_covariances.len(),
has_srp: u8::from(self.srp.is_some()),
srp_amrat: self.srp.map(|s| s.amrat).unwrap_or(0.0),
srp_cr: self.srp.map(|s| s.cr).unwrap_or(0.0),
srp_amrat_variance: self.srp.and_then(|s| s.amrat_variance).unwrap_or(f64::NAN),
state_param_cross: state_param_cross_ptr,
n_state_param_cross: state_param_cross.len(),
param_pair_cross: param_pair_cross_ptr,
n_param_pair_cross: param_pair_cross.len(),
has_phot_covariance: u8::from(self.phot_covariance.is_some()),
phot_covariance: self.phot_covariance.unwrap_or([[0.0; 3]; 3]),
};
let keep = OrbitFfiKeep {
_orbit_id: orbit_id_cstr,
_object_id: object_id_cstr,
_thrust_arcs: thrust_arcs,
_dv_corrections: dv_corrections,
_correction_covariances: correction_covariances,
_state_param_cross: state_param_cross,
_param_pair_cross: param_pair_cross,
};
Ok((ffi, keep))
}
}
const PHOT_COV_SYMMETRY_RTOL: f64 = 1e-12;
fn validate_photometry_covariance_symmetry(cov: &[[f64; 3]; 3]) -> crate::error::Result<()> {
const NAME: [&str; 3] = ["H", "slope1", "slope2"];
for (i, row) in cov.iter().enumerate() {
for (j, v) in row.iter().enumerate() {
if !v.is_finite() {
return Err(crate::error::Error::invalid_input(format!(
"photometric covariance entry [{i}][{j}] ({}–{}) is {v}, not a finite \
number. A non-finite entry contracts to a NaN σ_V, which reads \
downstream as 'no photometric uncertainty was supplied' — supply the \
real variance, or leave the covariance absent.",
NAME[i], NAME[j],
)));
}
}
}
for i in 0..3 {
for j in (i + 1)..3 {
let (a, b) = (cov[i][j], cov[j][i]);
let scale = a.abs().max(b.abs());
if (a - b).abs() > PHOT_COV_SYMMETRY_RTOL * scale {
return Err(crate::error::Error::invalid_input(format!(
"photometric covariance is not symmetric: [{i}][{j}] = {a} ({}–{}) but \
[{j}][{i}] = {b}. It is a covariance over (H, slope1, slope2), and the \
orbit file formats store only its lower triangle, so an asymmetric \
matrix cannot round-trip — mirror the term you meant onto both sides.",
NAME[i], NAME[j],
)));
}
}
}
Ok(())
}
pub(crate) struct OrbitFfiKeep {
_orbit_id: std::ffi::CString,
_object_id: std::ffi::CString,
_thrust_arcs: Vec<empyrean_sys::EmpyreanThrustArc>,
_dv_corrections: Vec<[f64; 3]>,
_correction_covariances: Vec<[[f64; 3]; 3]>,
_state_param_cross: Vec<empyrean_sys::EmpyreanStateParamCross>,
_param_pair_cross: Vec<empyrean_sys::EmpyreanParamPairCross>,
}
pub(crate) fn orbits_to_ffi(
orbits: &[Orbit],
) -> crate::error::Result<(Vec<empyrean_sys::EmpyreanOrbit>, Vec<OrbitFfiKeep>)> {
let mut keep: Vec<OrbitFfiKeep> = Vec::with_capacity(orbits.len());
let ffi = orbits
.iter()
.map(|o| {
let (f, k) = o.to_ffi_with_keep()?;
keep.push(k);
Ok(f)
})
.collect::<crate::error::Result<Vec<_>>>()?;
Ok((ffi, keep))
}
#[cfg(test)]
mod srp_tests {
use super::*;
use crate::Epoch;
use crate::coordinate::{CoordinateState, Frame, Origin};
fn cartesian_orbit() -> Orbit {
Orbit::new(CoordinateState::cartesian(
Epoch::from_mjd_tdb(59000.0),
[1.0, 0.1, 0.05, -0.005, 0.015, 0.001],
Frame::EclipticJ2000,
Origin::Sun,
))
}
#[test]
fn no_srp_marshals_absent() {
let (ffi, _keep) = cartesian_orbit().to_ffi_with_keep().unwrap();
assert_eq!(ffi.has_srp, 0);
assert!(
SrpParams::from_ffi(
ffi.srp_amrat,
ffi.srp_cr,
ffi.has_srp,
ffi.srp_amrat_variance
)
.is_none()
);
}
#[test]
fn fixed_force_srp_round_trips() {
let o = cartesian_orbit().with_srp(3.0e-3, 1.2);
let (ffi, _keep) = o.to_ffi_with_keep().unwrap();
assert_eq!(ffi.has_srp, 1);
assert_eq!(ffi.srp_amrat, 3.0e-3);
assert_eq!(ffi.srp_cr, 1.2);
assert!(ffi.srp_amrat_variance.is_nan()); let back = SrpParams::from_ffi(
ffi.srp_amrat,
ffi.srp_cr,
ffi.has_srp,
ffi.srp_amrat_variance,
)
.unwrap();
assert_eq!(
back,
SrpParams {
amrat: 3.0e-3,
cr: 1.2,
amrat_variance: None
}
);
}
#[test]
fn fittable_srp_round_trips() {
let o = cartesian_orbit()
.with_srp(3.0e-3, 1.0)
.with_srp_amrat_variance(Some(1.0e-8));
let (ffi, _keep) = o.to_ffi_with_keep().unwrap();
assert_eq!(ffi.srp_amrat_variance, 1.0e-8);
let back = SrpParams::from_ffi(
ffi.srp_amrat,
ffi.srp_cr,
ffi.has_srp,
ffi.srp_amrat_variance,
)
.unwrap();
assert_eq!(back.amrat_variance, Some(1.0e-8));
}
#[test]
fn amrat_variance_without_srp_is_noop() {
let o = cartesian_orbit().with_srp_amrat_variance(Some(1.0e-8));
assert!(o.srp.is_none());
}
}
#[cfg(test)]
mod photometry_marshal_tests {
use super::*;
use crate::Epoch;
use crate::coordinate::{CoordinateState, Frame, Origin};
fn cartesian_orbit() -> Orbit {
Orbit::new(CoordinateState::cartesian(
Epoch::from_mjd_tdb(59000.0),
[1.0, 0.1, 0.05, -0.005, 0.015, 0.001],
Frame::EclipticJ2000,
Origin::Sun,
))
}
#[test]
fn a_symmetric_covariance_marshals_unchanged() {
let cov = [
[0.09, -0.0042, 0.0],
[-0.0042, 0.0025, 0.0],
[0.0, 0.0, 0.0],
];
let o = cartesian_orbit()
.with_photometry(PhaseFunction::HG, 19.7, 0.15, 0.0)
.with_photometry_covariance(Some(cov));
let (ffi, _keep) = o.to_ffi_with_keep().expect("symmetric must marshal");
assert_eq!(ffi.has_phot_covariance, 1);
assert_eq!(ffi.phot_covariance, cov);
}
#[test]
fn round_off_asymmetry_is_tolerated() {
let mut cov = [
[0.09, -0.0042, 0.0],
[-0.0042, 0.0025, 0.0],
[0.0, 0.0, 0.0],
];
cov[1][0] = cov[0][1] * (1.0 + 1.0e-15);
let o = cartesian_orbit()
.with_photometry(PhaseFunction::HG, 19.7, 0.15, 0.0)
.with_photometry_covariance(Some(cov));
assert!(o.to_ffi_with_keep().is_ok());
}
#[test]
fn an_asymmetric_covariance_is_refused_by_name() {
let cov = [[0.09, -0.02, 0.0], [0.0, 0.0025, 0.0], [0.0, 0.0, 0.0]];
let o = cartesian_orbit()
.with_photometry(PhaseFunction::HG, 19.7, 0.15, 0.0)
.with_photometry_covariance(Some(cov));
let err = match o.to_ffi_with_keep() {
Ok(_) => panic!("an asymmetric photometric covariance must not marshal"),
Err(e) => e.to_string(),
};
assert!(
err.contains("not symmetric") && err.contains("[0][1]"),
"the error must name the offending pair, got: {err}"
);
assert!(
err.contains("H") && err.contains("slope1"),
"and name the parameters, got: {err}"
);
}
#[test]
fn a_non_finite_covariance_entry_is_refused_on_its_own_axis() {
let cov = [[0.09, 0.0, 0.0], [0.0, f64::NAN, 0.0], [0.0, 0.0, 0.0]];
let o = cartesian_orbit()
.with_photometry(PhaseFunction::HG, 19.7, 0.15, 0.0)
.with_photometry_covariance(Some(cov));
let err = match o.to_ffi_with_keep() {
Ok(_) => panic!("a NaN covariance entry must not marshal"),
Err(e) => e.to_string(),
};
assert!(
err.contains("[1][1]") && err.contains("finite"),
"the finiteness axis must be named, not blamed on symmetry: {err}"
);
}
#[test]
fn photometry_with_a_zero_magnitude_is_refused_rather_than_dropped() {
let o = cartesian_orbit().with_photometry(PhaseFunction::HG, 0.0, 0.15, 0.0);
let err = match o.to_ffi_with_keep() {
Ok(_) => panic!("H = 0.0 must be refused, not silently dropped"),
Err(e) => e.to_string(),
};
assert!(
err.contains("h_mag = 0.0"),
"the error must name the value: {err}"
);
}
#[test]
fn no_photometry_marshals_as_absent() {
let (ffi, _keep) = cartesian_orbit().to_ffi_with_keep().unwrap();
assert_eq!(ffi.phot_system, -1);
assert!(ffi.h_mag.is_nan());
assert_eq!(ffi.has_phot_covariance, 0);
}
}