use crate::joint::ParamDisposition;
use std::ffi::CStr;
use crate::observers::obs_code_from_bytes;
use crate::orbit::Orbit;
use crate::propagate::{CovarianceKind, ForceModelTier, PropagatedState};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RejectionReason {
Accepted,
ChiSquared,
SigmaClip,
CooksDistance,
Adaptive,
UnsupportedObservatory,
CMC2003,
RadarObservationsUnsupported,
OccultationObservationsUnsupported,
OutsideArc,
NonFiniteChi2,
MissingJacobian,
NotEvaluated,
}
impl RejectionReason {
pub(super) fn from_int(v: i32) -> Self {
match v {
0 => RejectionReason::Accepted,
1 => RejectionReason::ChiSquared,
2 => RejectionReason::SigmaClip,
3 => RejectionReason::CooksDistance,
4 => RejectionReason::Adaptive,
5 => RejectionReason::UnsupportedObservatory,
6 => RejectionReason::CMC2003,
7 => RejectionReason::RadarObservationsUnsupported,
8 => RejectionReason::OccultationObservationsUnsupported,
9 => RejectionReason::OutsideArc,
10 => RejectionReason::NonFiniteChi2,
11 => RejectionReason::MissingJacobian,
_ => RejectionReason::NotEvaluated,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ObservationResidual {
pub obs_id: String,
pub object_id: Option<String>,
pub obs_code: String,
pub ast_cat: Option<String>,
pub epoch: crate::Epoch,
pub ra_residual_arcsec: f64,
pub dec_residual_arcsec: f64,
pub chi2: f64,
pub dof: u32,
pub probability: f64,
pub selected: bool,
pub residual_cov_ra: f64,
pub residual_cov_dec: f64,
pub residual_cov_corr: f64,
pub rejection_reason: RejectionReason,
pub rejection_criterion: f64,
pub rejection_threshold: f64,
pub rejection_effective_threshold: f64,
pub rejection_information_loss: f64,
pub cooks_distance: f64,
pub leverage: f64,
pub fractional_information: f64,
pub along_track_arcsec: f64,
pub cross_track_arcsec: f64,
pub along_track_error_arcsec: f64,
pub cross_track_error_arcsec: f64,
pub track_position_angle_deg: f64,
pub influence_information_loss: f64,
pub along_cross_covariance_arcsec2: f64,
pub radar: Option<RadarResidual>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RadarResidualKind {
Delay,
Doppler,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RadarResidual {
pub kind: RadarResidualKind,
pub residual: f64,
pub chi2: f64,
pub dof: u32,
pub probability: f64,
pub variance: Option<f64>,
}
impl ObservationResidual {
pub(super) fn from_ffi(r: &empyrean_sys::EmpyreanObservationResult) -> Self {
let obs_id = if r.obs_id.is_null() {
String::new()
} else {
unsafe { CStr::from_ptr(r.obs_id) }
.to_string_lossy()
.into_owned()
};
let ast_cat = if r.ast_cat.is_null() {
None
} else {
let s = unsafe { CStr::from_ptr(r.ast_cat) }
.to_string_lossy()
.into_owned();
(!s.is_empty()).then_some(s)
};
let object_id = if r.object_id.is_null() {
None
} else {
let s = unsafe { CStr::from_ptr(r.object_id) }
.to_string_lossy()
.into_owned();
(!s.is_empty()).then_some(s)
};
Self {
obs_id,
object_id,
obs_code: obs_code_from_bytes(&r.obs_code),
ast_cat,
epoch: crate::Epoch::from_mjd_tdb(r.epoch_mjd_tdb),
ra_residual_arcsec: r.ra_residual_arcsec,
dec_residual_arcsec: r.dec_residual_arcsec,
chi2: r.chi2,
dof: r.dof,
probability: r.probability,
selected: r.selected != 0,
residual_cov_ra: r.residual_cov_ra,
residual_cov_dec: r.residual_cov_dec,
residual_cov_corr: r.residual_cov_corr,
rejection_reason: RejectionReason::from_int(r.rejection_reason),
rejection_criterion: r.rejection_criterion,
rejection_threshold: r.rejection_threshold,
rejection_effective_threshold: r.rejection_effective_threshold,
rejection_information_loss: r.rejection_information_loss,
cooks_distance: r.cooks_distance,
leverage: r.leverage,
fractional_information: r.fractional_information,
along_track_arcsec: r.along_track_arcsec,
cross_track_arcsec: r.cross_track_arcsec,
along_track_error_arcsec: r.along_track_error_arcsec,
cross_track_error_arcsec: r.cross_track_error_arcsec,
track_position_angle_deg: r.track_position_angle_deg,
influence_information_loss: r.influence_information_loss,
along_cross_covariance_arcsec2: r.along_cross_covariance_arcsec2,
radar: (r.has_radar != 0).then(|| RadarResidual {
kind: if r.radar_kind == empyrean_sys::EMPYREAN_RADAR_KIND_DOPPLER as u8 {
RadarResidualKind::Doppler
} else {
RadarResidualKind::Delay
},
residual: r.radar_residual,
chi2: r.radar_chi2,
dof: r.radar_dof,
probability: r.radar_probability,
variance: r.radar_variance.is_finite().then_some(r.radar_variance),
}),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ResidualSummary {
pub num_obs: usize,
pub num_selected: usize,
pub num_rejected: usize,
pub chi2: f64,
pub dof: usize,
pub reduced_chi2: f64,
pub rms_ra_arcsec: f64,
pub rms_dec_arcsec: f64,
pub rms_combined_arcsec: f64,
pub weighted_rms_ra_arcsec: f64,
pub weighted_rms_dec_arcsec: f64,
pub weighted_rms_combined_arcsec: f64,
pub mean_ra_arcsec: f64,
pub mean_dec_arcsec: f64,
pub std_ra_arcsec: f64,
pub std_dec_arcsec: f64,
pub rms_along_track_arcsec: f64,
pub rms_cross_track_arcsec: f64,
}
impl ResidualSummary {
pub(super) fn from_ffi(s: &empyrean_sys::EmpyreanResidualSummary) -> Self {
Self {
num_obs: s.num_obs,
num_selected: s.num_selected,
num_rejected: s.num_rejected,
chi2: s.chi2,
dof: s.dof,
reduced_chi2: s.reduced_chi2,
rms_ra_arcsec: s.rms_ra_arcsec,
rms_dec_arcsec: s.rms_dec_arcsec,
rms_combined_arcsec: s.rms_combined_arcsec,
weighted_rms_ra_arcsec: s.weighted_rms_ra_arcsec,
weighted_rms_dec_arcsec: s.weighted_rms_dec_arcsec,
weighted_rms_combined_arcsec: s.weighted_rms_combined_arcsec,
mean_ra_arcsec: s.mean_ra_arcsec,
mean_dec_arcsec: s.mean_dec_arcsec,
std_ra_arcsec: s.std_ra_arcsec,
std_dec_arcsec: s.std_dec_arcsec,
rms_along_track_arcsec: s.rms_along_track_arcsec,
rms_cross_track_arcsec: s.rms_cross_track_arcsec,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CovarianceRepresentation {
Cartesian,
Keplerian,
Cometary,
Spherical,
}
impl CovarianceRepresentation {
pub(super) fn from_int(v: i32) -> Self {
match v {
0 => Self::Cartesian,
1 => Self::Keplerian,
2 => Self::Cometary,
_ => Self::Spherical,
}
}
}
pub const MAX_THRUST_SEGMENTS: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SolveFor {
pub marsden: ParamDisposition,
pub dt: ParamDisposition,
pub amrat: ParamDisposition,
pub thrust: [ParamDisposition; MAX_THRUST_SEGMENTS],
}
impl Default for SolveFor {
fn default() -> Self {
Self {
marsden: ParamDisposition::Fixed,
dt: ParamDisposition::Fixed,
amrat: ParamDisposition::Fixed,
thrust: [ParamDisposition::Fixed; MAX_THRUST_SEGMENTS],
}
}
}
impl SolveFor {
pub fn with_leading_thrust(mut self, segments: usize) -> crate::error::Result<Self> {
if segments > MAX_THRUST_SEGMENTS {
return Err(crate::error::Error::invalid_input(format!(
"requested {segments} thrust segments but the engine's \
maximum is {MAX_THRUST_SEGMENTS} — the 17-column wide budget \
is shared across the state, Marsden, DT, AMRAT and thrust \
axes"
)));
}
for d in self.thrust.iter_mut().take(segments) {
*d = ParamDisposition::Solved;
}
Ok(self)
}
pub fn solved_thrust_segments(&self) -> usize {
self.thrust.iter().filter(|d| d.is_solved()).count()
}
pub fn considered_thrust_segments(&self) -> usize {
self.thrust.iter().filter(|d| d.is_considered()).count()
}
fn from_covariance(cov: &SolvedCovariance) -> Self {
let d = |present: bool| {
if present {
ParamDisposition::Solved
} else {
ParamDisposition::Fixed
}
};
let mut thrust = [ParamDisposition::Fixed; MAX_THRUST_SEGMENTS];
for slot in thrust.iter_mut().take(cov.thrust_slots.len()) {
*slot = ParamDisposition::Solved;
}
Self {
marsden: d(cov.marsden_slot.is_some()),
dt: d(cov.dt_slot.is_some()),
amrat: d(cov.amrat_slot.is_some()),
thrust,
}
}
pub(crate) fn to_ffi(self) -> empyrean_sys::EmpyreanSolveFor {
let mut thrust_dispositions = [ParamDisposition::Fixed.to_ffi(); MAX_THRUST_SEGMENTS];
for (i, d) in self.thrust.iter().enumerate() {
thrust_dispositions[i] = d.to_ffi();
}
empyrean_sys::EmpyreanSolveFor {
marsden: self.marsden.to_ffi(),
dt: self.dt.to_ffi(),
amrat: self.amrat.to_ffi(),
thrust_dispositions,
}
}
pub(crate) fn from_ffi(f: &empyrean_sys::EmpyreanSolveFor) -> crate::error::Result<Self> {
let mut thrust = [ParamDisposition::Fixed; MAX_THRUST_SEGMENTS];
for (i, d) in f.thrust_dispositions.iter().enumerate() {
thrust[i] = ParamDisposition::from_ffi(*d, &format!("thrust[{i}]"))?;
}
Ok(Self {
marsden: ParamDisposition::from_ffi(f.marsden, "marsden")?,
dt: ParamDisposition::from_ffi(f.dt, "dt")?,
amrat: ParamDisposition::from_ffi(f.amrat, "amrat")?,
thrust,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SolveForParams {
StateOnly,
StateAndNonGrav,
Auto,
Explicit(SolveFor),
}
impl SolveForParams {
pub(super) fn from_result(code: i32, cov: Option<&SolvedCovariance>) -> Self {
match code {
0 => Self::StateOnly,
1 => Self::StateAndNonGrav,
3 => Self::Explicit(cov.map(SolveFor::from_covariance).unwrap_or_default()),
_ => Self::Auto,
}
}
pub(super) fn to_int(self) -> i32 {
match self {
Self::StateOnly => 0,
Self::StateAndNonGrav => 1,
Self::Auto => 2,
Self::Explicit(_) => 3,
}
}
pub(super) fn flags(self) -> SolveFor {
match self {
Self::StateOnly | Self::Auto => SolveFor::default(),
Self::StateAndNonGrav => SolveFor {
marsden: ParamDisposition::Solved,
..SolveFor::default()
},
Self::Explicit(sf) => sf,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum OutputEpoch {
#[default]
MidArc,
LastObservation,
IODEpoch,
Epoch(f64),
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum OriginPolicy {
#[default]
Auto,
Explicit(crate::coordinate::Origin),
}
impl From<crate::coordinate::Origin> for OriginPolicy {
fn from(origin: crate::coordinate::Origin) -> Self {
Self::Explicit(origin)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AcceptabilityReport {
pub fit_acceptable: bool,
pub extrapolation_acceptable: bool,
pub converged_ok: bool,
pub reduced_chi2_ok: bool,
pub reduced_chi2_value: f64,
pub reduced_chi2_threshold: f64,
pub rms_ok: bool,
pub rms_value_arcsec: f64,
pub rms_threshold_arcsec: f64,
pub residual_isotropy_ok: bool,
pub at_ct_ratio_value: f64,
pub at_ct_ratio_threshold: f64,
pub covariance_ok: bool,
pub arc_coverage_ok: bool,
pub arc_days_value: f64,
pub arc_days_threshold: f64,
pub fractional_sigma_a_ok: bool,
pub fractional_sigma_a_value: f64,
pub fractional_sigma_a_threshold: f64,
pub selection_fraction_ok: bool,
pub selection_fraction_value: f64,
pub selection_fraction_threshold: f64,
pub selected_arc_coverage_ok: bool,
pub selected_arc_days_value: f64,
pub selected_arc_fraction_value: f64,
pub selected_arc_fraction_threshold: f64,
pub trailing_gap_ok: bool,
pub trailing_gap_days_value: f64,
pub trailing_gap_threshold: f64,
pub radar_fit_ok: Option<bool>,
}
impl AcceptabilityReport {
pub(super) fn from_ffi(r: &empyrean_sys::EmpyreanAcceptabilityReport) -> Self {
Self {
fit_acceptable: r.fit_acceptable != 0,
extrapolation_acceptable: r.extrapolation_acceptable != 0,
converged_ok: r.converged_ok != 0,
reduced_chi2_ok: r.reduced_chi2_ok != 0,
reduced_chi2_value: r.reduced_chi2_value,
reduced_chi2_threshold: r.reduced_chi2_threshold,
rms_ok: r.rms_ok != 0,
rms_value_arcsec: r.rms_value_arcsec,
rms_threshold_arcsec: r.rms_threshold_arcsec,
residual_isotropy_ok: r.residual_isotropy_ok != 0,
at_ct_ratio_value: r.at_ct_ratio_value,
at_ct_ratio_threshold: r.at_ct_ratio_threshold,
covariance_ok: r.covariance_ok != 0,
arc_coverage_ok: r.arc_coverage_ok != 0,
arc_days_value: r.arc_days_value,
arc_days_threshold: r.arc_days_threshold,
fractional_sigma_a_ok: r.fractional_sigma_a_ok != 0,
fractional_sigma_a_value: r.fractional_sigma_a_value,
fractional_sigma_a_threshold: r.fractional_sigma_a_threshold,
selection_fraction_ok: r.selection_fraction_ok != 0,
selection_fraction_value: r.selection_fraction_value,
selection_fraction_threshold: r.selection_fraction_threshold,
selected_arc_coverage_ok: r.selected_arc_coverage_ok != 0,
selected_arc_days_value: r.selected_arc_days_value,
selected_arc_fraction_value: r.selected_arc_fraction_value,
selected_arc_fraction_threshold: r.selected_arc_fraction_threshold,
trailing_gap_ok: r.trailing_gap_ok != 0,
trailing_gap_days_value: r.trailing_gap_days_value,
trailing_gap_threshold: r.trailing_gap_threshold,
radar_fit_ok: match r.radar_fit_ok {
1 => Some(true),
0 => Some(false),
_ => None,
},
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct StationBias {
pub obs_code: String,
pub n_obs: usize,
pub bias_ra_arcsec: f64,
pub sigma_ra_arcsec: f64,
pub bias_dec_arcsec: f64,
pub sigma_dec_arcsec: f64,
pub bias_timing_sec: Option<f64>,
pub sigma_timing_sec: Option<f64>,
pub significance: f64,
}
impl StationBias {
pub(super) fn from_ffi(b: &empyrean_sys::EmpyreanStationBias) -> Self {
let obs_code = if b.obs_code.is_null() {
String::new()
} else {
unsafe { CStr::from_ptr(b.obs_code) }
.to_string_lossy()
.into_owned()
};
let (bias_t, sigma_t) = if b.has_timing != 0 {
(Some(b.bias_timing_sec), Some(b.sigma_timing_sec))
} else {
(None, None)
};
Self {
obs_code,
n_obs: b.n_obs,
bias_ra_arcsec: b.bias_ra_arcsec,
sigma_ra_arcsec: b.sigma_ra_arcsec,
bias_dec_arcsec: b.bias_dec_arcsec,
sigma_dec_arcsec: b.sigma_dec_arcsec,
bias_timing_sec: bias_t,
sigma_timing_sec: sigma_t,
significance: b.significance,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SolvedCovariance {
pub matrix: Vec<Vec<f64>>,
pub width: usize,
pub marsden_slot: Option<usize>,
pub dt_slot: Option<usize>,
pub amrat_slot: Option<usize>,
pub thrust_slots: Vec<[usize; 3]>,
}
impl SolvedCovariance {
pub(super) fn from_ffi(c: &empyrean_sys::EmpyreanSolvedCovariance) -> Self {
let width = c.width as usize;
let matrix = (0..width)
.map(|i| (0..width).map(|j| c.matrix[i][j]).collect())
.collect();
let slot = |v: u32| (v != empyrean_sys::EMPYREAN_SLOT_NONE).then_some(v as usize);
let thrust_slots = (0..c.thrust_count as usize)
.map(|i| {
let r = c.thrust_slots[i];
[r[0] as usize, r[1] as usize, r[2] as usize]
})
.collect();
Self {
matrix,
width,
marsden_slot: slot(c.marsden_slot),
dt_slot: slot(c.dt_slot),
amrat_slot: slot(c.amrat_slot),
thrust_slots,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PhotometryModel {
#[default]
Auto,
HOnly,
HG,
HG12,
HG1G2,
}
impl PhotometryModel {
pub(super) fn to_int(self) -> i32 {
(match self {
Self::Auto => empyrean_sys::EMPYREAN_PHOTOMETRY_MODEL_AUTO,
Self::HOnly => empyrean_sys::EMPYREAN_PHOTOMETRY_MODEL_HONLY,
Self::HG => empyrean_sys::EMPYREAN_PHOTOMETRY_MODEL_HG,
Self::HG12 => empyrean_sys::EMPYREAN_PHOTOMETRY_MODEL_HG12,
Self::HG1G2 => empyrean_sys::EMPYREAN_PHOTOMETRY_MODEL_HG1G2,
}) as i32
}
fn from_int(v: i32) -> Self {
match v as u32 {
empyrean_sys::EMPYREAN_PHOTOMETRY_MODEL_HONLY => Self::HOnly,
empyrean_sys::EMPYREAN_PHOTOMETRY_MODEL_HG => Self::HG,
empyrean_sys::EMPYREAN_PHOTOMETRY_MODEL_HG12 => Self::HG12,
empyrean_sys::EMPYREAN_PHOTOMETRY_MODEL_HG1G2 => Self::HG1G2,
_ => Self::Auto,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BandStat {
pub band: String,
pub n: usize,
pub offset_applied: f64,
pub mean_residual: f64,
pub rms: f64,
}
impl BandStat {
fn from_ffi(b: &empyrean_sys::EmpyreanBandStat) -> Self {
let band = if b.band.is_null() {
String::new()
} else {
unsafe { CStr::from_ptr(b.band) }
.to_string_lossy()
.into_owned()
};
Self {
band,
n: b.n,
offset_applied: b.offset_applied,
mean_residual: b.mean_residual,
rms: b.rms,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct GateRecord {
pub model: PhotometryModel,
pub passed: bool,
pub reason: String,
}
impl GateRecord {
fn from_ffi(g: &empyrean_sys::EmpyreanGateRecord) -> Self {
let reason = if g.reason.is_null() {
String::new()
} else {
unsafe { CStr::from_ptr(g.reason) }
.to_string_lossy()
.into_owned()
};
Self {
model: PhotometryModel::from_int(g.model),
passed: g.passed != 0,
reason,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PhotometryResult {
pub h: f64,
pub slope1: f64,
pub slope2: f64,
pub covariance: Option<[[f64; 3]; 3]>,
pub model_used: PhotometryModel,
pub reduced_chi2: f64,
pub constraint_active: bool,
pub n_mags_used: usize,
pub n_mags_rejected_photometric: usize,
pub n_obs_without_mags: usize,
pub n_mags_from_astrometric_selected: usize,
pub n_mags_from_astrometric_rejected: usize,
pub alpha_min_deg: f64,
pub alpha_max_deg: f64,
pub alpha_span_deg: f64,
pub per_band: Vec<BandStat>,
pub gates: Vec<GateRecord>,
pub n_mags_dropped_unconvertible: usize,
pub dropped_bands: Vec<String>,
}
impl PhotometryResult {
pub(super) fn from_ffi(p: &empyrean_sys::EmpyreanODPhotometryResult) -> Self {
let covariance = (p.has_covariance != 0).then_some(p.covariance);
let per_band = if p.per_band.is_null() || p.num_per_band == 0 {
Vec::new()
} else {
unsafe {
std::slice::from_raw_parts(p.per_band, p.num_per_band)
.iter()
.map(BandStat::from_ffi)
.collect()
}
};
let gates = if p.gates.is_null() || p.num_gates == 0 {
Vec::new()
} else {
unsafe {
std::slice::from_raw_parts(p.gates, p.num_gates)
.iter()
.map(GateRecord::from_ffi)
.collect()
}
};
Self {
h: p.h,
slope1: p.slope1,
slope2: p.slope2,
covariance,
model_used: PhotometryModel::from_int(p.model_used),
reduced_chi2: p.reduced_chi2,
constraint_active: p.constraint_active != 0,
n_mags_used: p.n_mags_used,
n_mags_rejected_photometric: p.n_mags_rejected_photometric,
n_obs_without_mags: p.n_obs_without_mags,
n_mags_from_astrometric_selected: p.n_mags_from_astrometric_selected,
n_mags_from_astrometric_rejected: p.n_mags_from_astrometric_rejected,
alpha_min_deg: p.alpha_min_deg,
alpha_max_deg: p.alpha_max_deg,
alpha_span_deg: p.alpha_span_deg,
per_band,
gates,
n_mags_dropped_unconvertible: p.n_mags_dropped_unconvertible,
dropped_bands: if p.dropped_bands.is_null() || p.num_dropped_bands == 0 {
Vec::new()
} else {
unsafe {
std::slice::from_raw_parts(p.dropped_bands, p.num_dropped_bands)
.iter()
.map(|&b| {
if b.is_null() {
String::new()
} else {
CStr::from_ptr(b).to_string_lossy().into_owned()
}
})
.collect()
}
},
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum TrustGateEvent {
CloseApproach {
body: String,
epoch: crate::Epoch,
distance_au: f64,
},
HighNonlinearity {
epoch: crate::Epoch,
nonlinearity: f64,
threshold: f64,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum CovarianceTrust {
Trusted,
EncounterIntervenes {
event: TrustGateEvent,
solved_width: u32,
second_order_recoverable: bool,
},
WeaklyDeterminedHighN {
solved_width: u32,
},
}
impl CovarianceTrust {
pub(super) fn from_ffi(r: &empyrean_sys::EmpyreanODResult) -> Option<Self> {
match r.covariance_trust {
empyrean_sys::EMPYREAN_COVARIANCE_TRUST_TRUSTED => Some(CovarianceTrust::Trusted),
empyrean_sys::EMPYREAN_COVARIANCE_TRUST_ENCOUNTER_INTERVENES => {
let event =
if r.trust_event_kind == empyrean_sys::EMPYREAN_TRUST_EVENT_HIGH_NONLINEARITY {
TrustGateEvent::HighNonlinearity {
epoch: crate::Epoch::from_mjd_tdb(r.trust_event_epoch_mjd_tdb),
nonlinearity: r.trust_event_nonlinearity,
threshold: r.trust_event_threshold,
}
} else {
TrustGateEvent::CloseApproach {
body: if r.trust_event_body.is_null() {
String::new()
} else {
unsafe { CStr::from_ptr(r.trust_event_body) }
.to_string_lossy()
.into_owned()
},
epoch: crate::Epoch::from_mjd_tdb(r.trust_event_epoch_mjd_tdb),
distance_au: r.trust_event_distance_au,
}
};
Some(CovarianceTrust::EncounterIntervenes {
event,
solved_width: r.trust_solved_width,
second_order_recoverable: r.trust_second_order_recoverable != 0,
})
}
empyrean_sys::EMPYREAN_COVARIANCE_TRUST_WEAKLY_DETERMINED_HIGH_N => {
Some(CovarianceTrust::WeaklyDeterminedHighN {
solved_width: r.trust_solved_width,
})
}
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SolverStop {
GradientTolerance,
StepTolerance,
CostTolerance,
MaxIterations,
DampingExhausted,
InnerTrialsExhausted,
StalledDelivered,
SchurStepTolerance,
Unrecognized,
}
impl SolverStop {
pub(super) fn from_int(code: i32) -> Option<Self> {
match code {
empyrean_sys::EMPYREAN_SOLVER_STOP_NOT_REPORTED => None,
empyrean_sys::EMPYREAN_SOLVER_STOP_GRADIENT_TOLERANCE => {
Some(SolverStop::GradientTolerance)
}
empyrean_sys::EMPYREAN_SOLVER_STOP_STEP_TOLERANCE => Some(SolverStop::StepTolerance),
empyrean_sys::EMPYREAN_SOLVER_STOP_COST_TOLERANCE => Some(SolverStop::CostTolerance),
empyrean_sys::EMPYREAN_SOLVER_STOP_MAX_ITERATIONS => Some(SolverStop::MaxIterations),
empyrean_sys::EMPYREAN_SOLVER_STOP_DAMPING_EXHAUSTED => {
Some(SolverStop::DampingExhausted)
}
empyrean_sys::EMPYREAN_SOLVER_STOP_INNER_TRIALS_EXHAUSTED => {
Some(SolverStop::InnerTrialsExhausted)
}
empyrean_sys::EMPYREAN_SOLVER_STOP_STALLED_DELIVERED => {
Some(SolverStop::StalledDelivered)
}
empyrean_sys::EMPYREAN_SOLVER_STOP_SCHUR_STEP_TOLERANCE => {
Some(SolverStop::SchurStepTolerance)
}
_ => Some(SolverStop::Unrecognized),
}
}
pub fn as_str(&self) -> &'static str {
match self {
SolverStop::GradientTolerance => "gradient_tolerance",
SolverStop::StepTolerance => "step_tolerance",
SolverStop::CostTolerance => "cost_tolerance",
SolverStop::MaxIterations => "max_iterations",
SolverStop::DampingExhausted => "damping_exhausted",
SolverStop::InnerTrialsExhausted => "inner_trials_exhausted",
SolverStop::StalledDelivered => "stalled_delivered",
SolverStop::SchurStepTolerance => "schur_step_tolerance",
SolverStop::Unrecognized => "unrecognized",
}
}
pub fn is_solver_criterion(&self) -> bool {
matches!(
self,
SolverStop::GradientTolerance
| SolverStop::StepTolerance
| SolverStop::CostTolerance
| SolverStop::SchurStepTolerance
)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StallDelivery {
pub underlying_stop: SolverStop,
pub gn_qnorm: f64,
pub convergence_tol: f64,
pub optical_reduced_chi2: f64,
}
impl StallDelivery {
pub fn step_sigmas(&self) -> f64 {
self.gn_qnorm.sqrt()
}
pub(super) fn from_ffi(r: &empyrean_sys::EmpyreanODResult) -> Option<Self> {
(r.stall_delivered == 1).then(|| StallDelivery {
underlying_stop: SolverStop::from_int(r.stall_underlying_stop)
.unwrap_or(SolverStop::Unrecognized),
gn_qnorm: r.stall_gn_qnorm,
convergence_tol: r.stall_convergence_tol,
optical_reduced_chi2: r.stall_optical_reduced_chi2,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct DetermineResult {
pub orbit: Orbit,
pub residuals: Vec<ObservationResidual>,
pub summary: ResidualSummary,
pub iterations: u32,
pub update_norm: f64,
pub converged: bool,
pub covariance: [[f64; 6]; 6],
pub covariance_representation: CovarianceRepresentation,
pub covariance_9x9: Option<[[f64; 9]; 9]>,
pub non_grav_delta: Option<[f64; 3]>,
pub rejection_passes: u32,
pub num_oppositions_fit: u32,
pub force_model_used: ForceModelTier,
pub solve_for_used: SolveForParams,
pub acceptability: AcceptabilityReport,
pub station_biases: Vec<StationBias>,
pub solved_covariance: Option<SolvedCovariance>,
pub dt_delta: Option<f64>,
pub amrat_delta: Option<f64>,
pub thrust_delta_m_per_s: Vec<Option<[f64; 3]>>,
pub thrust_correction_covariances: Vec<Option<[[f64; 3]; 3]>>,
pub dv_frame: Option<crate::coordinate::Frame>,
pub photometry: Option<PhotometryResult>,
pub covariance_trust: Option<CovarianceTrust>,
pub dispositions: SolveFor,
pub warnings: Vec<String>,
pub termination: Option<SolverStop>,
pub gn_step_qnorm: Option<f64>,
pub mu_final: Option<f64>,
pub accepted_steps: u32,
pub final_solve_iterations: Option<usize>,
pub stall_delivery: Option<StallDelivery>,
}
impl DetermineResult {
pub fn state(&self) -> PropagatedState {
let st = &self.orbit.state;
let e = st.elements;
PropagatedState {
epoch: st.epoch,
position: [e[0], e[1], e[2]],
velocity: [e[3], e[4], e[5]],
origin: st.origin,
frame: st.frame,
covariance: Some(self.covariance),
joint: crate::JointCovariance {
non_grav_cross: st.non_grav_cross,
wide_cross: self.orbit.wide_cross.clone(),
},
stm: None,
stt: None,
resolved_kind: CovarianceKind::Linear,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct EvaluateResult {
pub residuals: Vec<ObservationResidual>,
pub summary: ResidualSummary,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetermineFailureKind {
ObservationConversion,
ObserverConstruction,
UnsupportedCoordinateSystem,
EarthOrientationCoverage,
IOD,
OD,
DuplicateObsIds,
RadarOnly,
NonGravNotRecovered,
Unknown(i32),
}
impl DetermineFailureKind {
pub(super) fn from_code(code: i32) -> Self {
match code {
empyrean_sys::EMPYREAN_OD_FAILURE_OBSERVATION_CONVERSION => Self::ObservationConversion,
empyrean_sys::EMPYREAN_OD_FAILURE_OBSERVER_CONSTRUCTION => Self::ObserverConstruction,
empyrean_sys::EMPYREAN_OD_FAILURE_UNSUPPORTED_COORDINATE_SYSTEM => {
Self::UnsupportedCoordinateSystem
}
empyrean_sys::EMPYREAN_OD_FAILURE_EARTH_ORIENTATION_COVERAGE => {
Self::EarthOrientationCoverage
}
empyrean_sys::EMPYREAN_OD_FAILURE_IOD => Self::IOD,
empyrean_sys::EMPYREAN_OD_FAILURE_OD => Self::OD,
empyrean_sys::EMPYREAN_OD_FAILURE_DUPLICATE_OBS_IDS => Self::DuplicateObsIds,
empyrean_sys::EMPYREAN_OD_FAILURE_RADAR_ONLY => Self::RadarOnly,
empyrean_sys::EMPYREAN_OD_FAILURE_NON_GRAV_NOT_RECOVERED => Self::NonGravNotRecovered,
other => Self::Unknown(other),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetermineFailure {
pub object_id: String,
pub message: String,
pub kind: DetermineFailureKind,
}
impl std::fmt::Display for DetermineFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.object_id, self.message)
}
}
impl std::error::Error for DetermineFailure {}
#[derive(Debug, Clone, PartialEq)]
pub struct DetermineEntry {
pub object_id: String,
pub outcome: std::result::Result<DetermineResult, DetermineFailure>,
}
impl DetermineEntry {
pub fn result(&self) -> Option<&DetermineResult> {
self.outcome.as_ref().ok()
}
pub fn failure(&self) -> Option<&DetermineFailure> {
self.outcome.as_ref().err()
}
pub fn delivered(&self) -> bool {
self.outcome.is_ok()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct DetermineResults {
entries: Vec<DetermineEntry>,
unmatched_orbit_ids: Vec<String>,
}
impl DetermineResults {
pub fn from_entries(entries: Vec<DetermineEntry>, unmatched_orbit_ids: Vec<String>) -> Self {
Self {
entries,
unmatched_orbit_ids,
}
}
pub(crate) fn new(entries: Vec<DetermineEntry>, unmatched_orbit_ids: Vec<String>) -> Self {
Self::from_entries(entries, unmatched_orbit_ids)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn iter(&self) -> std::slice::Iter<'_, DetermineEntry> {
self.entries.iter()
}
pub fn object_ids(&self) -> impl Iterator<Item = &str> {
self.entries.iter().map(|e| e.object_id.as_str())
}
pub fn get(&self, object_id: &str) -> Option<&DetermineEntry> {
self.entries.iter().find(|e| e.object_id == object_id)
}
pub fn delivered(&self) -> impl Iterator<Item = (&str, &DetermineResult)> {
self.entries
.iter()
.filter_map(|e| e.result().map(|r| (e.object_id.as_str(), r)))
}
pub fn delivered_count(&self) -> usize {
self.entries.iter().filter(|e| e.delivered()).count()
}
pub fn failures(&self) -> impl Iterator<Item = &DetermineFailure> {
self.entries.iter().filter_map(|e| e.failure())
}
pub fn all_failed(&self) -> bool {
!self.entries.is_empty() && self.delivered_count() == 0
}
pub fn unmatched_orbit_ids(&self) -> &[String] {
&self.unmatched_orbit_ids
}
pub fn into_single(self) -> crate::error::Result<DetermineResult> {
match self.entries.len() {
1 => {
let entry = self.entries.into_iter().next().expect("length checked");
entry.outcome.map_err(|f| {
crate::error::Error::invalid_input(format!(
"orbit determination failed for {}: {}",
f.object_id, f.message
))
})
}
0 => Err(crate::error::Error::invalid_input(
"orbit determination produced no objects: the observations \
carried no rows to group",
)),
n => {
let ids: Vec<&str> = self.object_ids().collect();
Err(crate::error::Error::invalid_input(format!(
"orbit determination fitted {n} objects ({}); into_single \
refuses to choose one — iterate the batch or select with \
`get(object_id)`",
ids.join(", ")
)))
}
}
}
}
impl<'a> IntoIterator for &'a DetermineResults {
type Item = &'a DetermineEntry;
type IntoIter = std::slice::Iter<'a, DetermineEntry>;
fn into_iter(self) -> Self::IntoIter {
self.entries.iter()
}
}
impl IntoIterator for DetermineResults {
type Item = DetermineEntry;
type IntoIter = std::vec::IntoIter<DetermineEntry>;
fn into_iter(self) -> Self::IntoIter {
self.entries.into_iter()
}
}
#[cfg(test)]
mod batch_tests {
use super::*;
use crate::coordinate::{Frame, Origin};
use crate::propagate::ForceModelTier;
fn summary() -> ResidualSummary {
ResidualSummary {
num_obs: 42,
num_selected: 40,
num_rejected: 2,
chi2: 38.0,
dof: 34,
reduced_chi2: 1.12,
rms_ra_arcsec: 0.31,
rms_dec_arcsec: 0.28,
rms_combined_arcsec: 0.30,
weighted_rms_ra_arcsec: 0.9,
weighted_rms_dec_arcsec: 0.8,
weighted_rms_combined_arcsec: 0.85,
mean_ra_arcsec: 0.01,
mean_dec_arcsec: -0.02,
std_ra_arcsec: 0.31,
std_dec_arcsec: 0.28,
rms_along_track_arcsec: f64::NAN,
rms_cross_track_arcsec: f64::NAN,
}
}
fn acceptability() -> AcceptabilityReport {
AcceptabilityReport {
fit_acceptable: true,
extrapolation_acceptable: true,
converged_ok: true,
reduced_chi2_ok: true,
reduced_chi2_value: 1.12,
reduced_chi2_threshold: 2.0,
rms_ok: true,
rms_value_arcsec: 0.31,
rms_threshold_arcsec: 1.0,
residual_isotropy_ok: true,
at_ct_ratio_value: f64::NAN,
at_ct_ratio_threshold: 3.0,
covariance_ok: true,
arc_coverage_ok: true,
arc_days_value: 900.0,
arc_days_threshold: 30.0,
fractional_sigma_a_ok: true,
fractional_sigma_a_value: 1.0e-6,
fractional_sigma_a_threshold: 1.0e-3,
selection_fraction_ok: true,
selection_fraction_value: 40.0 / 42.0,
selection_fraction_threshold: 0.7,
selected_arc_coverage_ok: true,
selected_arc_days_value: 890.0,
selected_arc_fraction_value: 0.99,
selected_arc_fraction_threshold: 0.8,
trailing_gap_ok: true,
trailing_gap_days_value: 0.0,
trailing_gap_threshold: 30.0,
radar_fit_ok: None,
}
}
fn fit() -> DetermineResult {
let state = crate::CoordinateState::cartesian(
crate::Epoch::from_mjd_tdb(60000.0),
[1.0, 0.1, 0.02, -0.003, 0.017, 0.0006],
Frame::EclipticJ2000,
Origin::Sun,
);
DetermineResult {
orbit: crate::Orbit::new(state),
residuals: Vec::new(),
summary: summary(),
iterations: 5,
update_norm: 1.0e-12,
converged: true,
covariance: [[0.0; 6]; 6],
covariance_representation: CovarianceRepresentation::Cartesian,
covariance_9x9: None,
non_grav_delta: None,
rejection_passes: 1,
num_oppositions_fit: 3,
force_model_used: ForceModelTier::Standard,
solve_for_used: SolveForParams::StateOnly,
acceptability: acceptability(),
station_biases: Vec::new(),
solved_covariance: None,
dt_delta: None,
amrat_delta: None,
thrust_delta_m_per_s: Vec::new(),
thrust_correction_covariances: Vec::new(),
dispositions: SolveFor::default(),
warnings: Vec::new(),
dv_frame: None,
photometry: None,
covariance_trust: None,
termination: None,
gn_step_qnorm: None,
mu_final: None,
accepted_steps: 0,
final_solve_iterations: None,
stall_delivery: None,
}
}
fn delivered(object_id: &str) -> DetermineEntry {
DetermineEntry {
object_id: object_id.to_string(),
outcome: Ok(fit()),
}
}
fn failed(object_id: &str, kind: DetermineFailureKind) -> DetermineEntry {
DetermineEntry {
object_id: object_id.to_string(),
outcome: Err(DetermineFailure {
object_id: object_id.to_string(),
message: "no viable IOD seed".to_string(),
kind,
}),
}
}
#[test]
fn failures_stay_in_the_table() {
let batch = DetermineResults::new(
vec![
delivered("2024 YR4"),
failed("K25A00B", DetermineFailureKind::IOD),
delivered("433"),
],
Vec::new(),
);
assert_eq!(batch.len(), 3, "every input object keeps its row");
assert_eq!(batch.delivered_count(), 2);
assert_eq!(batch.failures().count(), 1);
assert!(!batch.all_failed());
assert_eq!(
batch.object_ids().collect::<Vec<_>>(),
vec!["2024 YR4", "K25A00B", "433"]
);
}
#[test]
fn lookup_finds_delivered_and_failed_objects() {
let batch = DetermineResults::new(
vec![
delivered("2024 YR4"),
failed("K25A00B", DetermineFailureKind::RadarOnly),
],
Vec::new(),
);
let hit = batch.get("2024 YR4").expect("delivered object is findable");
assert!(hit.delivered());
assert!(hit.result().is_some());
assert!(hit.failure().is_none());
let miss = batch.get("K25A00B").expect("failed object is findable");
assert!(!miss.delivered());
assert!(miss.result().is_none());
let f = miss.failure().expect("carries a typed failure");
assert_eq!(f.kind, DetermineFailureKind::RadarOnly);
assert_eq!(f.object_id, "K25A00B");
assert!(f.to_string().contains("K25A00B"));
assert!(batch.get("1997 XF11").is_none(), "absent object is None");
}
#[test]
fn into_single_refuses_a_multi_object_batch() {
let batch =
DetermineResults::new(vec![delivered("2024 YR4"), delivered("433")], Vec::new());
let err = batch.into_single().expect_err("must refuse to choose");
assert!(err.message.contains("2 objects"), "{}", err.message);
assert!(err.message.contains("2024 YR4"), "{}", err.message);
assert!(err.message.contains("433"), "{}", err.message);
}
#[test]
fn into_single_unwraps_the_one_object_case() {
let batch = DetermineResults::new(vec![delivered("2024 YR4")], Vec::new());
let fit = batch.into_single().expect("one delivered object unwraps");
assert!(fit.converged);
assert_eq!(fit.summary.num_obs, 42);
}
#[test]
fn into_single_surfaces_the_failure_of_a_lone_failed_object() {
let batch = DetermineResults::new(
vec![failed("K25A00B", DetermineFailureKind::OD)],
Vec::new(),
);
let err = batch.into_single().expect_err("a failed fit is not a fit");
assert!(err.message.contains("K25A00B"), "{}", err.message);
assert!(
err.message.contains("no viable IOD seed"),
"{}",
err.message
);
}
#[test]
fn into_single_reports_an_empty_batch_as_such() {
let batch = DetermineResults::new(Vec::new(), Vec::new());
assert!(batch.is_empty());
let err = batch.into_single().expect_err("nothing to unwrap");
assert!(err.message.contains("no objects"), "{}", err.message);
}
#[test]
fn all_failed_is_detectable_with_per_object_reasons() {
let batch = DetermineResults::new(
vec![
failed("A", DetermineFailureKind::IOD),
failed("B", DetermineFailureKind::EarthOrientationCoverage),
],
Vec::new(),
);
assert!(batch.all_failed());
assert_eq!(batch.delivered_count(), 0);
let kinds: Vec<_> = batch.failures().map(|f| f.kind).collect();
assert_eq!(
kinds,
vec![
DetermineFailureKind::IOD,
DetermineFailureKind::EarthOrientationCoverage
]
);
}
#[test]
fn an_empty_batch_is_not_all_failed() {
assert!(!DetermineResults::new(Vec::new(), Vec::new()).all_failed());
}
#[test]
fn unmatched_seeds_are_reported() {
let batch =
DetermineResults::new(vec![delivered("2024 YR4")], vec!["1997 XF11".to_string()]);
assert_eq!(batch.unmatched_orbit_ids(), ["1997 XF11"]);
}
#[test]
fn unknown_failure_codes_keep_their_raw_value() {
assert_eq!(
DetermineFailureKind::from_code(9999),
DetermineFailureKind::Unknown(9999)
);
assert_eq!(
DetermineFailureKind::from_code(empyrean_sys::EMPYREAN_OD_FAILURE_IOD),
DetermineFailureKind::IOD
);
assert_eq!(
DetermineFailureKind::from_code(
empyrean_sys::EMPYREAN_OD_FAILURE_NON_GRAV_NOT_RECOVERED
),
DetermineFailureKind::NonGravNotRecovered
);
}
#[test]
fn iteration_covers_every_slot() {
let batch = DetermineResults::new(
vec![delivered("A"), failed("B", DetermineFailureKind::OD)],
Vec::new(),
);
assert_eq!(batch.iter().count(), 2);
assert_eq!((&batch).into_iter().count(), 2);
assert_eq!(batch.into_iter().count(), 2);
}
#[test]
fn fit_summary_rows_cover_failures_with_nan_not_zero() {
let batch = DetermineResults::new(
vec![
delivered("2024 YR4"),
failed("K25A00B", DetermineFailureKind::IOD),
],
Vec::new(),
);
let rows = crate::FitSummaryRow::from_results(&batch);
assert_eq!(rows.len(), 2, "one row per input object");
assert_eq!(rows[0].status, "delivered");
assert_eq!(rows[0].object_id, "2024 YR4");
assert!(rows[0].converged);
assert_eq!(rows[0].n_obs, 42);
assert_eq!(rows[0].n_selected, 40);
assert!(rows[0].error.is_none());
assert!(rows[0].extrapolation_acceptable);
assert_eq!(rows[0].trailing_gap_days, 0.0);
assert_eq!(rows[0].solve_for_width, 6);
assert_eq!(rows[1].status, "failed");
assert_eq!(rows[1].object_id, "K25A00B");
assert!(!rows[1].converged);
assert!(rows[1].reduced_chi2.is_nan(), "no fit means no χ², not 0");
assert!(rows[1].rms_ra_arcsec.is_nan());
assert!(rows[1].selection_fraction.is_nan());
assert!(rows[1].fractional_sigma_a.is_nan());
assert!(!rows[1].fit_acceptable && !rows[1].extrapolation_acceptable);
assert_eq!(rows[1].error.as_deref(), Some("no viable IOD seed"));
}
#[test]
fn solver_stop_criterion_line_matches_the_engine() {
use super::SolverStop as S;
let all = [
S::GradientTolerance,
S::StepTolerance,
S::CostTolerance,
S::MaxIterations,
S::DampingExhausted,
S::InnerTrialsExhausted,
S::StalledDelivered,
S::SchurStepTolerance,
S::Unrecognized,
];
let criteria: Vec<_> = all.iter().filter(|s| s.is_solver_criterion()).collect();
assert_eq!(
criteria,
[
&S::GradientTolerance,
&S::StepTolerance,
&S::CostTolerance,
&S::SchurStepTolerance
]
);
let mut tags: Vec<_> = all.iter().map(|s| s.as_str()).collect();
tags.sort_unstable();
tags.dedup();
assert_eq!(tags.len(), all.len());
}
#[test]
fn stall_step_sigmas_is_the_root_of_the_quadratic_form() {
let stall = super::StallDelivery {
underlying_stop: super::SolverStop::DampingExhausted,
gn_qnorm: 4.0e-4,
convergence_tol: 1.0e-5,
optical_reduced_chi2: 1.1,
};
assert_eq!(stall.step_sigmas(), 2.0e-2);
}
}