use crate::context::Context;
use crate::error::{Error, Result};
use crate::orbit::Orbit;
use crate::propagate::ForceModelTier;
use crate::time::Epoch;
use std::ffi::{CStr, CString};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RadarStation {
GoldstoneDSS14,
GreenBank,
Arecibo,
}
impl RadarStation {
fn to_ffi(self) -> u8 {
match self {
RadarStation::GoldstoneDSS14 => 0,
RadarStation::GreenBank => 1,
RadarStation::Arecibo => 2,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RadarMode {
Delay,
Doppler,
Both,
}
impl RadarMode {
fn to_ffi(self) -> u8 {
match self {
RadarMode::Delay => 0,
RadarMode::Doppler => 1,
RadarMode::Both => 2,
}
}
fn from_ffi(tag: i32) -> Option<Self> {
match tag {
0 => Some(RadarMode::Delay),
1 => Some(RadarMode::Doppler),
2 => Some(RadarMode::Both),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct TargetRadarProperties {
pub h_mag: Option<f64>,
pub visual_albedo: Option<f64>,
pub radar_albedo: Option<f64>,
pub diameter_km: Option<f64>,
pub spin_period_hours: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RadarPlanSpec {
pub transmit_station: RadarStation,
pub receive_station: RadarStation,
pub mode: RadarMode,
pub bandwidth_hz: f64,
pub freq_resolution_hz: f64,
pub snr: Option<f64>,
pub target: TargetRadarProperties,
pub integration_s: f64,
}
impl RadarPlanSpec {
pub fn given(
transmit_station: RadarStation,
receive_station: RadarStation,
mode: RadarMode,
bandwidth_hz: f64,
freq_resolution_hz: f64,
snr: f64,
) -> Self {
Self {
transmit_station,
receive_station,
mode,
bandwidth_hz,
freq_resolution_hz,
snr: Some(snr),
target: TargetRadarProperties::default(),
integration_s: 0.0,
}
}
pub fn link_budget(
transmit_station: RadarStation,
receive_station: RadarStation,
target: TargetRadarProperties,
integration_s: f64,
mode: RadarMode,
bandwidth_hz: f64,
freq_resolution_hz: f64,
) -> Self {
Self {
transmit_station,
receive_station,
mode,
bandwidth_hz,
freq_resolution_hz,
snr: None,
target,
integration_s,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum PlannedObservationKind {
Optical {
optical_code: String,
optical_sigma_arcsec: [f64; 2],
},
Radar(Box<RadarPlanSpec>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlannedObservation {
pub epoch: Epoch,
pub kind: PlannedObservationKind,
}
impl PlannedObservation {
pub fn optical(
optical_code: impl Into<String>,
optical_sigma_arcsec: [f64; 2],
epoch: Epoch,
) -> Self {
Self {
epoch,
kind: PlannedObservationKind::Optical {
optical_code: optical_code.into(),
optical_sigma_arcsec,
},
}
}
pub fn radar(spec: RadarPlanSpec, epoch: Epoch) -> Self {
Self {
epoch,
kind: PlannedObservationKind::Radar(Box::new(spec)),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ObservatoryConfig {
pub obs_code: String,
pub sigma_arcsec: [f64; 2],
pub max_apparent_mag: f64,
pub min_elongation_deg: f64,
pub min_elevation_deg: f64,
pub max_sun_altitude_deg: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlanningConfig {
pub force_model: ForceModelTier,
pub epsilon: f64,
pub observatories: Vec<ObservatoryConfig>,
pub num_threads: Option<usize>,
}
impl Default for PlanningConfig {
fn default() -> Self {
Self {
force_model: ForceModelTier::Standard,
epsilon: 1e-9,
observatories: Vec::new(),
num_threads: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CovarianceMetrics {
pub position_sigma_km: f64,
pub velocity_sigma_m_s: f64,
pub semi_major_km: f64,
pub semi_minor_km: f64,
pub log_det: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PlanEphemerisPoint {
pub epoch: Epoch,
pub ra_deg: f64,
pub dec_deg: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CandidateKind {
Optical,
Radar {
mode: RadarMode,
snr: f64,
range_km: f64,
provenance: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlanCandidate {
pub index: usize,
pub obs_code: String,
pub kind: CandidateKind,
pub observable: bool,
pub along_track_sigma_arcsec: Option<f64>,
pub cross_track_sigma_arcsec: Option<f64>,
pub ra_sigma_arcsec: Option<f64>,
pub dec_sigma_arcsec: Option<f64>,
pub position_angle_deg: Option<f64>,
pub marginal_volume_reduction: f64,
pub marginal_position_improvement: f64,
pub post_along_track_sigma_arcsec: Option<f64>,
pub post_cross_track_sigma_arcsec: Option<f64>,
pub cumulative: CovarianceMetrics,
pub active_width: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlanResult {
pub orbit_id: String,
pub prior: CovarianceMetrics,
pub posterior: CovarianceMetrics,
pub candidates: Vec<PlanCandidate>,
pub active_width: usize,
pub ephemeris: Vec<PlanEphemerisPoint>,
}
unsafe fn cstr_to_string(p: *mut std::ffi::c_char) -> String {
if p.is_null() {
String::new()
} else {
unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()
}
}
fn nan_from_opt(v: Option<f64>) -> f64 {
v.unwrap_or(f64::NAN)
}
fn optical_only(is_optical: bool, v: f64) -> Option<f64> {
if is_optical { Some(v) } else { None }
}
fn reject_non_finite(value: Option<f64>, what: &str, index: usize) -> Result<()> {
if let Some(v) = value
&& !v.is_finite()
{
return Err(Error::invalid_input(format!(
"planned observation {index}: {what} must be finite, got {v}; pass None to leave it \
absent"
)));
}
Ok(())
}
fn metrics_from_ffi(m: empyrean_sys::EmpyreanCovarianceMetrics) -> CovarianceMetrics {
let empyrean_sys::EmpyreanCovarianceMetrics {
position_sigma_km,
velocity_sigma_m_s,
semi_major_km,
semi_minor_km,
log_det,
} = m;
CovarianceMetrics {
position_sigma_km,
velocity_sigma_m_s,
semi_major_km,
semi_minor_km,
log_det,
}
}
struct PlanFfiKeep {
optical_codes: Vec<CString>,
obs_codes: Vec<CString>,
orbit_id: Option<CString>,
}
fn reject_unread_config_knobs(config: &PlanningConfig) -> Result<()> {
if !config.observatories.is_empty() {
return Err(Error::invalid_input(format!(
"PlanningConfig::observatories is not consulted by evaluate_plan (got {} \
entries); each optical candidate's σ comes from its own PlannedObservation. \
Observability filters are engine-set on this entry point and are not \
caller-configurable.",
config.observatories.len()
)));
}
if let Some(n) = config.num_threads {
return Err(Error::invalid_input(format!(
"PlanningConfig::num_threads is not consulted by evaluate_plan (got {n}); it \
evaluates a single orbit and does not shard the work. Leave it as None."
)));
}
Ok(())
}
fn reject_unusable_orbit_basis(orbit: &Orbit) -> Result<()> {
if orbit.state.representation != crate::Representation::Cartesian {
return Err(Error::invalid_input(format!(
"evaluate_plan requires a Cartesian orbit, got {:?}. The covariance is consumed as a \
6×6 Cartesian state prior and is never converted, so elements in any other \
representation would be reinterpreted rather than rejected. Convert with \
Context::transform_coordinates_single(.., Representation::Cartesian, .., \
Origin::SSB).",
orbit.state.representation
)));
}
if orbit.state.origin != crate::Origin::SSB {
return Err(Error::invalid_input(format!(
"evaluate_plan requires an orbit with its origin at the Solar System barycenter, got \
{:?}. Convert with Context::transform_coordinates_single(.., \
Representation::Cartesian, .., Origin::SSB) — an origin shift is a pure translation, \
so the covariance and its metrics are unchanged.",
orbit.state.origin
)));
}
Ok(())
}
fn resolve_orbit_id<'a>(arg: Option<&'a str>, orbit_id: Option<&'a str>) -> Option<&'a str> {
[arg, orbit_id]
.into_iter()
.flatten()
.find(|id| !id.is_empty())
}
fn reject_link_budget_inputs_with_given_snr(spec: &RadarPlanSpec, index: usize) -> Result<()> {
if spec.snr.is_none() {
return Ok(());
}
let mut unused: Vec<String> = Vec::new();
for (name, value) in [
("h_mag", spec.target.h_mag),
("visual_albedo", spec.target.visual_albedo),
("radar_albedo", spec.target.radar_albedo),
("diameter_km", spec.target.diameter_km),
("spin_period_hours", spec.target.spin_period_hours),
] {
if let Some(v) = value {
unused.push(format!("target.{name}={v}"));
}
}
if spec.integration_s != 0.0 {
unused.push(format!("integration_s={}", spec.integration_s));
}
if unused.is_empty() {
return Ok(());
}
Err(Error::invalid_input(format!(
"planned observation {index}: snr is supplied, so the link budget never runs and {} would \
be dropped. Set snr to None to derive the SNR from those properties, or remove them.",
unused.join(", ")
)))
}
fn cstring_for(value: &str, what: &str) -> Result<CString> {
CString::new(value).map_err(|_| {
Error::invalid_input(format!("{what} contains an interior nul byte: {value:?}"))
})
}
impl Context {
pub fn evaluate_plan(
&self,
orbit: &Orbit,
orbit_id: Option<&str>,
planned: &[PlannedObservation],
config: &PlanningConfig,
) -> Result<PlanResult> {
reject_unread_config_knobs(config)?;
reject_unusable_orbit_basis(orbit)?;
let (ffi_orbit, _orbit_keep) = orbit.to_ffi_with_keep()?;
let mut optical_codes: Vec<CString> = Vec::with_capacity(planned.len());
for p in planned {
let cs = match &p.kind {
PlannedObservationKind::Optical { optical_code, .. } => {
cstring_for(optical_code, "optical station code")?
}
PlannedObservationKind::Radar(_) => CString::default(),
};
optical_codes.push(cs);
}
let mut obs_codes: Vec<CString> = Vec::with_capacity(config.observatories.len());
for o in &config.observatories {
obs_codes.push(cstring_for(&o.obs_code, "observatory code")?);
}
let orbit_id_cstr = match resolve_orbit_id(orbit_id, orbit.orbit_id.as_deref()) {
Some(id) => Some(cstring_for(id, "orbit_id")?),
None => None,
};
let keep = PlanFfiKeep {
optical_codes,
obs_codes,
orbit_id: orbit_id_cstr,
};
let ffi_planned: Vec<empyrean_sys::EmpyreanPlannedObservation> = planned
.iter()
.zip(keep.optical_codes.iter())
.enumerate()
.map(|(i, (p, code))| planned_to_ffi(p, code, i))
.collect::<Result<Vec<_>>>()?;
let ffi_observatories: Vec<empyrean_sys::EmpyreanObservatoryConfig> = config
.observatories
.iter()
.zip(keep.obs_codes.iter())
.map(|(o, code)| empyrean_sys::EmpyreanObservatoryConfig {
obs_code: code.as_ptr(),
sigma_ra_arcsec: o.sigma_arcsec[0],
sigma_dec_arcsec: o.sigma_arcsec[1],
max_apparent_mag: o.max_apparent_mag,
min_elongation_deg: o.min_elongation_deg,
min_elevation_deg: o.min_elevation_deg,
has_max_sun_altitude_deg: u8::from(o.max_sun_altitude_deg.is_some()),
max_sun_altitude_deg: o.max_sun_altitude_deg.unwrap_or(0.0),
})
.collect();
let ffi_config = empyrean_sys::EmpyreanPlanningConfig {
force_model: config.force_model as i32,
epsilon: config.epsilon,
observatories: if ffi_observatories.is_empty() {
std::ptr::null()
} else {
ffi_observatories.as_ptr()
},
num_observatories: ffi_observatories.len(),
num_threads: match config.num_threads {
None => -1,
Some(n) => i32::try_from(n).map_err(|_| {
Error::invalid_input(format!("num_threads {n} exceeds the C ABI's i32 range"))
})?,
},
};
let mut ffi_result = empyrean_sys::EmpyreanPlanResult::default();
let code = unsafe {
empyrean_sys::empyrean_evaluate_plan(
self.as_raw(),
&ffi_orbit,
keep.orbit_id
.as_ref()
.map_or(std::ptr::null(), |c| c.as_ptr()),
ffi_planned.as_ptr(),
ffi_planned.len(),
&ffi_config,
&mut ffi_result,
)
};
if code != 0 {
return Err(Error::capture(code));
}
let copied = plan_result_from_ffi(&ffi_result);
unsafe { empyrean_sys::empyrean_plan_result_free(&mut ffi_result) };
drop(keep);
copied
}
}
fn planned_to_ffi(
p: &PlannedObservation,
optical_code: &CString,
index: usize,
) -> Result<empyrean_sys::EmpyreanPlannedObservation> {
let epoch_mjd_tdb = p.epoch.mjd_tdb()?;
if !epoch_mjd_tdb.is_finite() {
return Err(Error::invalid_input(format!(
"planned observation {index}: epoch must be a finite MJD TDB, got {epoch_mjd_tdb}"
)));
}
if let PlannedObservationKind::Radar(spec) = &p.kind {
if spec.transmit_station == RadarStation::GreenBank {
return Err(Error::invalid_input(format!(
"planned observation {index}: Green Bank is receive-only and cannot be the \
transmit station; pair it with a transmitting dish for a bistatic observation"
)));
}
reject_link_budget_inputs_with_given_snr(spec, index)?;
reject_non_finite(spec.snr, "radar snr", index)?;
reject_non_finite(spec.target.h_mag, "radar target h_mag", index)?;
reject_non_finite(
spec.target.visual_albedo,
"radar target visual_albedo",
index,
)?;
reject_non_finite(spec.target.radar_albedo, "radar target radar_albedo", index)?;
reject_non_finite(spec.target.diameter_km, "radar target diameter_km", index)?;
reject_non_finite(
spec.target.spin_period_hours,
"radar target spin_period_hours",
index,
)?;
}
Ok(match &p.kind {
PlannedObservationKind::Optical {
optical_code: _,
optical_sigma_arcsec,
} => empyrean_sys::EmpyreanPlannedObservation {
epoch_mjd_tdb,
kind: 0,
optical_code: optical_code.as_ptr(),
optical_sigma_ra_arcsec: optical_sigma_arcsec[0],
optical_sigma_dec_arcsec: optical_sigma_arcsec[1],
radar_transmit_station: 0,
radar_receive_station: 0,
radar_mode: 0,
radar_bandwidth_hz: 0.0,
radar_freq_resolution_hz: 0.0,
radar_snr: f64::NAN,
radar_target_h_mag: f64::NAN,
radar_target_visual_albedo: f64::NAN,
radar_target_radar_albedo: f64::NAN,
radar_target_diameter_km: f64::NAN,
radar_target_spin_period_hours: f64::NAN,
radar_integration_s: 0.0,
},
PlannedObservationKind::Radar(spec) => empyrean_sys::EmpyreanPlannedObservation {
epoch_mjd_tdb,
kind: 1,
optical_code: optical_code.as_ptr(),
optical_sigma_ra_arcsec: 0.0,
optical_sigma_dec_arcsec: 0.0,
radar_transmit_station: spec.transmit_station.to_ffi(),
radar_receive_station: spec.receive_station.to_ffi(),
radar_mode: spec.mode.to_ffi(),
radar_bandwidth_hz: spec.bandwidth_hz,
radar_freq_resolution_hz: spec.freq_resolution_hz,
radar_snr: nan_from_opt(spec.snr),
radar_target_h_mag: nan_from_opt(spec.target.h_mag),
radar_target_visual_albedo: nan_from_opt(spec.target.visual_albedo),
radar_target_radar_albedo: nan_from_opt(spec.target.radar_albedo),
radar_target_diameter_km: nan_from_opt(spec.target.diameter_km),
radar_target_spin_period_hours: nan_from_opt(spec.target.spin_period_hours),
radar_integration_s: spec.integration_s,
},
})
}
fn plan_result_from_ffi(ffi: &empyrean_sys::EmpyreanPlanResult) -> Result<PlanResult> {
let empyrean_sys::EmpyreanPlanResult {
orbit_id,
prior,
posterior,
candidates,
num_candidates,
active_width,
ephemeris,
num_ephemeris,
} = *ffi;
let mut out_candidates = Vec::with_capacity(num_candidates);
if !candidates.is_null() && num_candidates > 0 {
for i in 0..num_candidates {
out_candidates.push(candidate_from_ffi(unsafe { &*candidates.add(i) })?);
}
}
let mut out_ephemeris = Vec::with_capacity(num_ephemeris);
if !ephemeris.is_null() && num_ephemeris > 0 {
for i in 0..num_ephemeris {
let empyrean_sys::EmpyreanPlanEphemerisPoint {
epoch_mjd_tdb,
ra_deg,
dec_deg,
} = unsafe { *ephemeris.add(i) };
out_ephemeris.push(PlanEphemerisPoint {
epoch: Epoch::from_mjd_tdb(epoch_mjd_tdb),
ra_deg,
dec_deg,
});
}
}
Ok(PlanResult {
orbit_id: unsafe { cstr_to_string(orbit_id) },
prior: metrics_from_ffi(prior),
posterior: metrics_from_ffi(posterior),
candidates: out_candidates,
active_width,
ephemeris: out_ephemeris,
})
}
fn candidate_from_ffi(c: &empyrean_sys::EmpyreanPlanCandidate) -> Result<PlanCandidate> {
let empyrean_sys::EmpyreanPlanCandidate {
index,
obs_code,
kind,
observable,
along_track_sigma_arcsec,
cross_track_sigma_arcsec,
ra_sigma_arcsec,
dec_sigma_arcsec,
position_angle_deg,
marginal_volume_reduction,
marginal_position_improvement,
post_along_track_sigma_arcsec,
post_cross_track_sigma_arcsec,
cumulative,
active_width,
radar_snr,
radar_range_km,
radar_provenance,
num_radar_provenance,
radar_mode,
} = *c;
let out_kind = match kind {
0 => CandidateKind::Optical,
1 => {
let mode = RadarMode::from_ffi(radar_mode).ok_or_else(|| {
Error::invalid_input(format!(
"C ABI returned an unknown radar mode tag for a radar candidate: {radar_mode}"
))
})?;
let mut provenance = Vec::with_capacity(num_radar_provenance);
if !radar_provenance.is_null() {
for j in 0..num_radar_provenance {
provenance.push(unsafe { cstr_to_string(*radar_provenance.add(j)) });
}
}
CandidateKind::Radar {
mode,
snr: radar_snr,
range_km: radar_range_km,
provenance,
}
}
other => {
return Err(Error::invalid_input(format!(
"C ABI returned an unknown plan-candidate kind: {other}"
)));
}
};
let is_optical = matches!(out_kind, CandidateKind::Optical);
Ok(PlanCandidate {
index,
obs_code: unsafe { cstr_to_string(obs_code) },
kind: out_kind,
observable: observable != 0,
along_track_sigma_arcsec: optical_only(is_optical, along_track_sigma_arcsec),
cross_track_sigma_arcsec: optical_only(is_optical, cross_track_sigma_arcsec),
ra_sigma_arcsec: optical_only(is_optical, ra_sigma_arcsec),
dec_sigma_arcsec: optical_only(is_optical, dec_sigma_arcsec),
position_angle_deg: optical_only(is_optical, position_angle_deg),
marginal_volume_reduction,
marginal_position_improvement,
post_along_track_sigma_arcsec: optical_only(is_optical, post_along_track_sigma_arcsec),
post_cross_track_sigma_arcsec: optical_only(is_optical, post_cross_track_sigma_arcsec),
cumulative: metrics_from_ffi(cumulative),
active_width,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn optical_constructor_carries_code_and_sigma() {
let p = PlannedObservation::optical("F51", [0.25, 0.3], Epoch::from_mjd_tdb(61000.0));
match &p.kind {
PlannedObservationKind::Optical {
optical_code,
optical_sigma_arcsec,
} => {
assert_eq!(optical_code, "F51");
assert_eq!(*optical_sigma_arcsec, [0.25, 0.3]);
}
other => panic!("expected an optical candidate, got {other:?}"),
}
}
#[test]
fn given_snr_and_link_budget_specs_are_distinguishable() {
let given = RadarPlanSpec::given(
RadarStation::GoldstoneDSS14,
RadarStation::GreenBank,
RadarMode::Both,
1.0e5,
0.1,
42.0,
);
assert_eq!(given.snr, Some(42.0));
assert_eq!(given.target, TargetRadarProperties::default());
let budget = RadarPlanSpec::link_budget(
RadarStation::GoldstoneDSS14,
RadarStation::GoldstoneDSS14,
TargetRadarProperties {
h_mag: Some(19.7),
visual_albedo: Some(0.23),
..TargetRadarProperties::default()
},
600.0,
RadarMode::Delay,
1.0e5,
0.1,
);
assert_eq!(budget.snr, None);
assert_eq!(budget.integration_s, 600.0);
assert_eq!(budget.target.h_mag, Some(19.7));
}
#[test]
fn absent_link_budget_properties_lower_to_nan() {
let spec = RadarPlanSpec::link_budget(
RadarStation::GoldstoneDSS14,
RadarStation::GoldstoneDSS14,
TargetRadarProperties {
diameter_km: Some(0.34),
..TargetRadarProperties::default()
},
600.0,
RadarMode::Both,
1.0e5,
0.1,
);
let p = PlannedObservation::radar(spec, Epoch::from_mjd_tdb(61000.0));
let code = CString::default();
let ffi = planned_to_ffi(&p, &code, 0).expect("radar candidate lowers");
assert_eq!(ffi.kind, 1);
assert!(ffi.radar_snr.is_nan(), "absent SNR must lower to NaN");
assert_eq!(ffi.radar_target_diameter_km, 0.34);
assert!(ffi.radar_target_h_mag.is_nan());
assert!(ffi.radar_target_spin_period_hours.is_nan());
}
#[test]
fn radar_mode_tags_round_trip() {
for mode in [RadarMode::Delay, RadarMode::Doppler, RadarMode::Both] {
let tag = i32::from(mode.to_ffi());
assert_eq!(RadarMode::from_ffi(tag), Some(mode));
}
assert_eq!(RadarMode::from_ffi(-1), None);
assert_eq!(RadarMode::from_ffi(3), None);
}
#[test]
fn default_config_matches_the_engine_defaults() {
let cfg = PlanningConfig::default();
assert_eq!(cfg.num_threads, None);
assert_eq!(cfg.epsilon, 1e-9);
assert_eq!(cfg.force_model, ForceModelTier::Standard);
assert!(cfg.observatories.is_empty());
assert!(reject_unread_config_knobs(&cfg).is_ok());
}
#[test]
fn config_knobs_the_planner_never_reads_are_refused() {
let mut cfg = PlanningConfig {
observatories: vec![ObservatoryConfig {
obs_code: "F51".to_string(),
sigma_arcsec: [0.2, 0.2],
max_apparent_mag: 22.0,
min_elongation_deg: 45.0,
min_elevation_deg: 0.0,
max_sun_altitude_deg: None,
}],
..PlanningConfig::default()
};
let err = reject_unread_config_knobs(&cfg).expect_err("observatories must be refused");
assert!(err.message.contains("observatories"), "{}", err.message);
assert!(
err.message.contains("PlannedObservation"),
"the refusal must name the alternative: {}",
err.message
);
cfg = PlanningConfig {
num_threads: Some(4),
..PlanningConfig::default()
};
let err = reject_unread_config_knobs(&cfg).expect_err("num_threads must be refused");
assert!(err.message.contains("num_threads"), "{}", err.message);
}
#[test]
fn sky_plane_geometry_is_none_on_a_radar_row() {
assert_eq!(optical_only(true, 1.5), Some(1.5));
assert_eq!(optical_only(false, 0.0), None);
assert!(
optical_only(true, f64::NAN).is_some_and(f64::is_nan),
"a NaN on an optical row must not collapse into the not-applicable None"
);
}
fn optical_at(mjd: f64) -> PlannedObservation {
PlannedObservation::optical("F51", [0.2, 0.2], Epoch::from_mjd_tdb(mjd))
}
#[test]
fn a_non_finite_candidate_epoch_is_refused_before_the_engine() {
let code = CString::default();
let err = planned_to_ffi(&optical_at(f64::NAN), &code, 3)
.expect_err("a NaN epoch must be refused");
assert!(err.message.contains("epoch"), "{}", err.message);
assert!(
err.message.contains('3'),
"the message must name the candidate: {}",
err.message
);
}
#[test]
fn green_bank_is_refused_as_a_transmit_station() {
let spec = RadarPlanSpec::given(
RadarStation::GreenBank,
RadarStation::GoldstoneDSS14,
RadarMode::Both,
1.0e5,
0.1,
50.0,
);
let p = PlannedObservation::radar(spec, Epoch::from_mjd_tdb(61000.0));
let code = CString::default();
let err = planned_to_ffi(&p, &code, 0).expect_err("Green Bank cannot transmit");
assert!(err.message.contains("receive-only"), "{}", err.message);
}
#[test]
fn a_non_finite_radar_value_is_refused_rather_than_read_as_absent() {
let code = CString::default();
let mut spec = RadarPlanSpec::given(
RadarStation::GoldstoneDSS14,
RadarStation::GoldstoneDSS14,
RadarMode::Both,
1.0e5,
0.1,
f64::NAN,
);
let p = PlannedObservation::radar(spec.clone(), Epoch::from_mjd_tdb(61000.0));
let err = planned_to_ffi(&p, &code, 1).expect_err("Some(NaN) snr must be refused");
assert!(err.message.contains("snr"), "{}", err.message);
spec.snr = Some(50.0);
spec.target.h_mag = Some(f64::INFINITY);
let p = PlannedObservation::radar(spec, Epoch::from_mjd_tdb(61000.0));
let err = planned_to_ffi(&p, &code, 1).expect_err("a non-finite H must be refused");
assert!(err.message.contains("h_mag"), "{}", err.message);
}
#[test]
fn an_unknown_candidate_kind_from_the_c_abi_is_refused() {
let c = empyrean_sys::EmpyreanPlanCandidate {
kind: 2,
..Default::default()
};
let err = candidate_from_ffi(&c).expect_err("an unknown kind must be refused");
assert!(err.message.contains("kind"), "{}", err.message);
}
#[test]
fn an_unknown_radar_mode_from_the_c_abi_is_refused() {
let c = empyrean_sys::EmpyreanPlanCandidate {
kind: 1,
radar_mode: 7,
..Default::default()
};
let err = candidate_from_ffi(&c).expect_err("an unknown radar mode must be refused");
assert!(err.message.contains("radar mode"), "{}", err.message);
}
#[test]
fn an_interior_nul_in_a_borrowed_string_is_refused() {
let err = cstring_for("F5\0 1", "optical station code")
.expect_err("an interior nul must be refused");
assert!(
err.message.contains("optical station code"),
"{}",
err.message
);
}
#[test]
fn a_non_cartesian_orbit_is_refused_before_the_engine() {
let mut orbit = Orbit::new(crate::CoordinateState::cometary(
Epoch::from_mjd_tdb(61000.0),
[0.746, 0.191, 3.339, 204.446, 126.687, 60159.0],
crate::Frame::EclipticJ2000,
crate::Origin::SSB,
));
let err = reject_unusable_orbit_basis(&orbit).expect_err("cometary must be refused");
assert!(err.message.contains("Cartesian"), "{}", err.message);
assert!(
err.message.contains("transform_coordinates_single"),
"the refusal must name the conversion: {}",
err.message
);
orbit = Orbit::new(crate::CoordinateState::cartesian(
Epoch::from_mjd_tdb(61000.0),
[1.0, 0.0, 0.0, 0.0, 0.017, 0.0],
crate::Frame::EclipticJ2000,
crate::Origin::SUN,
));
let err = reject_unusable_orbit_basis(&orbit).expect_err("heliocentric must be refused");
assert!(err.message.contains("barycenter"), "{}", err.message);
assert!(
err.message.contains("transform_coordinates_single"),
"{}",
err.message
);
orbit = Orbit::new(crate::CoordinateState::cartesian(
Epoch::from_mjd_tdb(61000.0),
[1.0, 0.0, 0.0, 0.0, 0.017, 0.0],
crate::Frame::EclipticJ2000,
crate::Origin::SSB,
));
assert!(reject_unusable_orbit_basis(&orbit).is_ok());
}
#[test]
fn the_result_label_falls_back_to_the_orbits_own_id() {
assert_eq!(
resolve_orbit_id(Some("explicit"), Some("orbit")),
Some("explicit")
);
assert_eq!(resolve_orbit_id(None, Some("orbit")), Some("orbit"));
assert_eq!(resolve_orbit_id(Some(""), Some("orbit")), Some("orbit"));
assert_eq!(resolve_orbit_id(None, None), None);
assert_eq!(resolve_orbit_id(Some(""), Some("")), None);
}
#[test]
fn link_budget_inputs_are_refused_alongside_a_supplied_snr() {
let mut spec = RadarPlanSpec::given(
RadarStation::GoldstoneDSS14,
RadarStation::GoldstoneDSS14,
RadarMode::Both,
1.0e5,
0.1,
50.0,
);
spec.target.h_mag = Some(19.7);
spec.integration_s = 600.0;
let err = reject_link_budget_inputs_with_given_snr(&spec, 2)
.expect_err("link-budget inputs beside a supplied snr must be refused");
assert!(err.message.contains("target.h_mag"), "{}", err.message);
assert!(err.message.contains("integration_s"), "{}", err.message);
assert!(
err.message.contains("snr to None"),
"the refusal must name the fix: {}",
err.message
);
let budget = RadarPlanSpec::link_budget(
RadarStation::GoldstoneDSS14,
RadarStation::GoldstoneDSS14,
TargetRadarProperties {
h_mag: Some(19.7),
..TargetRadarProperties::default()
},
600.0,
RadarMode::Both,
1.0e5,
0.1,
);
assert!(reject_link_budget_inputs_with_given_snr(&budget, 0).is_ok());
}
}