use std::ffi::{CStr, c_char};
use std::path::PathBuf;
use std::ptr::NonNull;
use crate::context::Context;
use crate::coordinate::{Frame, int_to_frame};
use crate::ephemeris::{
EphemerisConfig, EphemerisResult, marshal_ephemeris_result, observers_to_ffi,
};
use crate::error::{Error, Result};
use crate::observers::Observer;
use crate::orbit::{Orbit, orbits_to_ffi};
use crate::propagate::{
ForceModelTier, PropagationConfig, PropagationResult, marshal_propagation_result,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuiltSystemGuardError {
DataMismatch,
KeyMismatchFrame,
KeyMismatchForceModel,
KeyMismatchDivisor,
Stale,
}
impl Error {
pub fn builtsystem_guard(&self) -> Option<BuiltSystemGuardError> {
match self.code {
c if c == empyrean_sys::EMPYREAN_BUILTSYSTEM_DATA_MISMATCH => {
Some(BuiltSystemGuardError::DataMismatch)
}
c if c == empyrean_sys::EMPYREAN_BUILTSYSTEM_KEY_MISMATCH_FRAME => {
Some(BuiltSystemGuardError::KeyMismatchFrame)
}
c if c == empyrean_sys::EMPYREAN_BUILTSYSTEM_KEY_MISMATCH_FORCE_MODEL => {
Some(BuiltSystemGuardError::KeyMismatchForceModel)
}
c if c == empyrean_sys::EMPYREAN_BUILTSYSTEM_KEY_MISMATCH_DIVISOR => {
Some(BuiltSystemGuardError::KeyMismatchDivisor)
}
c if c == empyrean_sys::EMPYREAN_BUILTSYSTEM_STALE => {
Some(BuiltSystemGuardError::Stale)
}
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KernelKind {
Spk,
Bpc,
Tpc,
Gravity,
ObsCodes,
}
#[derive(Debug, Clone, PartialEq)]
pub enum KernelProvenance {
File {
path: PathBuf,
sha256: String,
bytes: u64,
},
InMemory,
BuiltIn {
name: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct KernelRecord {
pub kind: KernelKind,
pub provenance: KernelProvenance,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SystemDescription {
pub force_model: ForceModelTier,
pub frame: Frame,
pub encounter_timescale_divisor: f64,
pub relativistic: bool,
pub asteroids: bool,
pub has_bpc: bool,
pub perturber_origins: Vec<i32>,
pub kernels: Vec<KernelRecord>,
}
pub struct BuiltSystem {
raw: NonNull<empyrean_sys::EmpyreanBuiltSystem>,
}
unsafe impl Send for BuiltSystem {}
unsafe impl Sync for BuiltSystem {}
const _: fn() = || {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<BuiltSystem>();
};
impl Context {
pub fn built_system(
&self,
force_model: ForceModelTier,
frame: Frame,
encounter_timescale_divisor: f64,
) -> Result<BuiltSystem> {
let mut raw: *mut empyrean_sys::EmpyreanBuiltSystem = std::ptr::null_mut();
let code = unsafe {
empyrean_sys::empyrean_builtsystem_new(
self.as_raw(),
force_model as i32,
frame as i32,
encounter_timescale_divisor,
&mut raw,
)
};
if code != 0 {
return Err(Error::capture(code));
}
NonNull::new(raw)
.map(|raw| BuiltSystem { raw })
.ok_or_else(Error::from_null_ptr)
}
}
impl BuiltSystem {
fn as_raw(&self) -> *const empyrean_sys::EmpyreanBuiltSystem {
self.raw.as_ptr()
}
pub fn propagate(
&self,
ctx: &Context,
orbits: &[Orbit],
epochs: &[crate::Epoch],
config: &PropagationConfig,
) -> Result<PropagationResult> {
let (ffi_orbits, _orbit_keep) = orbits_to_ffi(orbits)?;
let (ffi_config, _config_keep) = config.to_ffi_with();
let epochs_mjd_tdb: Vec<f64> = epochs
.iter()
.map(|e| e.mjd_tdb())
.collect::<Result<Vec<_>>>()?;
let mut ffi_result = empyrean_sys::EmpyreanPropagationResult::default();
let code = unsafe {
empyrean_sys::empyrean_builtsystem_propagate(
self.as_raw(),
ctx.as_raw(),
ffi_orbits.as_ptr(),
ffi_orbits.len(),
epochs_mjd_tdb.as_ptr(),
epochs_mjd_tdb.len(),
&ffi_config,
&mut ffi_result,
)
};
if code != 0 {
return Err(Error::capture(code));
}
marshal_propagation_result(ffi_result, orbits.len())
}
pub fn generate_ephemeris(
&self,
ctx: &Context,
orbits: &[Orbit],
observers: &[Observer],
config: &EphemerisConfig,
) -> Result<EphemerisResult> {
let (ffi_orbits, _orbit_keep) = orbits_to_ffi(orbits)?;
let ffi_observers = observers_to_ffi(observers)?;
let (ffi_config, _config_keep) = config.to_ffi_with();
let mut ffi_result = empyrean_sys::EmpyreanEphemerisResult::default();
let code = unsafe {
empyrean_sys::empyrean_builtsystem_generate_ephemeris(
self.as_raw(),
ctx.as_raw(),
ffi_orbits.as_ptr(),
ffi_orbits.len(),
ffi_observers.as_ptr(),
ffi_observers.len(),
&ffi_config,
&mut ffi_result,
)
};
if code != 0 {
return Err(Error::capture(code));
}
Ok(marshal_ephemeris_result(&mut ffi_result))
}
pub fn describe(&self) -> Result<SystemDescription> {
let mut ffi = empyrean_sys::EmpyreanSystemDescription::default();
let code = unsafe { empyrean_sys::empyrean_builtsystem_describe(self.as_raw(), &mut ffi) };
if code != 0 {
return Err(Error::capture(code));
}
let result = system_description_from_ffi(&ffi);
unsafe { empyrean_sys::empyrean_builtsystem_description_free(&mut ffi) };
result
}
}
impl Drop for BuiltSystem {
fn drop(&mut self) {
unsafe { empyrean_sys::empyrean_builtsystem_free(self.raw.as_ptr()) }
}
}
fn system_description_from_ffi(
d: &empyrean_sys::EmpyreanSystemDescription,
) -> Result<SystemDescription> {
let force_model = int_to_force_model_tier(d.force_model)?;
let frame = int_to_frame(d.frame)?;
let perturber_origins = if d.perturber_origins.is_null() || d.num_perturbers == 0 {
Vec::new()
} else {
unsafe { std::slice::from_raw_parts(d.perturber_origins, d.num_perturbers) }.to_vec()
};
let kernels = if d.kernels.is_null() || d.num_kernels == 0 {
Vec::new()
} else {
unsafe { std::slice::from_raw_parts(d.kernels, d.num_kernels) }
.iter()
.map(kernel_record_from_ffi)
.collect::<Result<Vec<_>>>()?
};
Ok(SystemDescription {
force_model,
frame,
encounter_timescale_divisor: d.encounter_timescale_divisor,
relativistic: d.relativistic != 0,
asteroids: d.asteroids != 0,
has_bpc: d.has_bpc != 0,
perturber_origins,
kernels,
})
}
fn kernel_record_from_ffi(r: &empyrean_sys::EmpyreanKernelRecord) -> Result<KernelRecord> {
let kind = int_to_kernel_kind(r.kind)?;
let provenance = match r.provenance {
p if p == empyrean_sys::EMPYREAN_KERNEL_PROVENANCE_FILE as i32 => {
let path = cstr_to_string(r.path)
.ok_or_else(|| Error::invalid_input("FILE kernel record has a null path"))?;
let sha256 = cstr_to_string(r.sha256)
.ok_or_else(|| Error::invalid_input("FILE kernel record has a null sha256"))?;
KernelProvenance::File {
path: PathBuf::from(path),
sha256,
bytes: r.bytes,
}
}
p if p == empyrean_sys::EMPYREAN_KERNEL_PROVENANCE_IN_MEMORY as i32 => {
KernelProvenance::InMemory
}
p if p == empyrean_sys::EMPYREAN_KERNEL_PROVENANCE_BUILT_IN as i32 => {
let name = cstr_to_string(r.name)
.ok_or_else(|| Error::invalid_input("BUILT_IN kernel record has a null name"))?;
KernelProvenance::BuiltIn { name }
}
other => {
return Err(Error::invalid_input(format!(
"unknown kernel provenance tag {other}"
)));
}
};
Ok(KernelRecord { kind, provenance })
}
fn int_to_kernel_kind(v: i32) -> Result<KernelKind> {
match v {
x if x == empyrean_sys::EMPYREAN_KERNEL_KIND_SPK as i32 => Ok(KernelKind::Spk),
x if x == empyrean_sys::EMPYREAN_KERNEL_KIND_BPC as i32 => Ok(KernelKind::Bpc),
x if x == empyrean_sys::EMPYREAN_KERNEL_KIND_TPC as i32 => Ok(KernelKind::Tpc),
x if x == empyrean_sys::EMPYREAN_KERNEL_KIND_GRAVITY as i32 => Ok(KernelKind::Gravity),
x if x == empyrean_sys::EMPYREAN_KERNEL_KIND_OBSCODES as i32 => Ok(KernelKind::ObsCodes),
other => Err(Error::invalid_input(format!(
"unknown kernel kind tag {other}"
))),
}
}
fn int_to_force_model_tier(v: i32) -> Result<ForceModelTier> {
match v {
0 => Ok(ForceModelTier::Approximate),
1 => Ok(ForceModelTier::Basic),
2 => Ok(ForceModelTier::Standard),
other => Err(Error::invalid_input(format!(
"unknown force-model tier code {other}"
))),
}
}
fn cstr_to_string(p: *const c_char) -> Option<String> {
if p.is_null() {
None
} else {
Some(unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CoordinateState, Epoch, Origin};
#[test]
fn handle_is_send_sync() {
fn requires_send_sync<T: Send + Sync>() {}
requires_send_sync::<BuiltSystem>();
requires_send_sync::<SystemDescription>();
requires_send_sync::<BuiltSystemGuardError>();
}
#[test]
fn guard_codes_classify_distinctly() {
let cases = [
(
empyrean_sys::EMPYREAN_BUILTSYSTEM_DATA_MISMATCH,
BuiltSystemGuardError::DataMismatch,
),
(
empyrean_sys::EMPYREAN_BUILTSYSTEM_KEY_MISMATCH_FRAME,
BuiltSystemGuardError::KeyMismatchFrame,
),
(
empyrean_sys::EMPYREAN_BUILTSYSTEM_KEY_MISMATCH_FORCE_MODEL,
BuiltSystemGuardError::KeyMismatchForceModel,
),
(
empyrean_sys::EMPYREAN_BUILTSYSTEM_KEY_MISMATCH_DIVISOR,
BuiltSystemGuardError::KeyMismatchDivisor,
),
(
empyrean_sys::EMPYREAN_BUILTSYSTEM_STALE,
BuiltSystemGuardError::Stale,
),
];
for (code, expected) in cases {
let err = Error {
code,
message: String::new(),
};
assert_eq!(err.builtsystem_guard(), Some(expected), "code {code}");
}
let ok = Error {
code: -3,
message: String::new(),
};
assert_eq!(ok.builtsystem_guard(), None);
}
fn try_ctx() -> Option<Context> {
Context::from_data_dir(None).ok()
}
fn base_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::ICRF,
Origin::SUN,
))
}
fn standard_icrf_config() -> PropagationConfig {
PropagationConfig {
force_model: ForceModelTier::Standard,
frame: Frame::ICRF,
..PropagationConfig::default()
}
}
#[test]
fn builtsystem_propagate_matches_one_shot() {
let ctx = match try_ctx() {
Some(c) => c,
None => {
eprintln!("skipping builtsystem_propagate_matches_one_shot: no data dir");
return;
}
};
let cfg = standard_icrf_config();
let orbits = [base_orbit()];
let epochs = [
Epoch::from_mjd_tdb(59000.0),
Epoch::from_mjd_tdb(59010.0),
Epoch::from_mjd_tdb(59030.0),
];
let one_shot = ctx.propagate(&orbits, &epochs, &cfg).expect("one-shot");
let system = ctx
.built_system(ForceModelTier::Standard, Frame::ICRF, 0.0)
.expect("build handle");
let via_handle = system
.propagate(&ctx, &orbits, &epochs, &cfg)
.expect("handle propagate");
assert_eq!(one_shot.states.len(), via_handle.states.len());
assert_eq!(one_shot.states.len(), epochs.len());
for (i, (a, b)) in one_shot
.states
.iter()
.zip(via_handle.states.iter())
.enumerate()
{
assert_eq!(
a.epoch.mjd_tdb().unwrap(),
b.epoch.mjd_tdb().unwrap(),
"epoch[{i}] bit-identical"
);
assert_eq!(a.position, b.position, "position[{i}] bit-identical");
assert_eq!(a.velocity, b.velocity, "velocity[{i}] bit-identical");
}
}
#[test]
fn builtsystem_generate_ephemeris_matches_one_shot() {
let ctx = match try_ctx() {
Some(c) => c,
None => {
eprintln!("skipping builtsystem_generate_ephemeris_matches_one_shot: no data dir");
return;
}
};
let eph_cfg = EphemerisConfig {
propagation: standard_icrf_config(),
..EphemerisConfig::default()
};
let orbits = [base_orbit()];
let observers = [Observer {
obs_code: "500".to_string(),
epoch: Epoch::from_mjd_tdb(59000.0),
position: [0.9, -0.42, -0.18],
velocity: [0.0075, 0.0148, 0.0064],
observing_night: -1,
}];
let one_shot = ctx
.generate_ephemeris(&orbits, &observers, &eph_cfg)
.expect("one-shot ephemeris");
let system = ctx
.built_system(ForceModelTier::Standard, Frame::ICRF, 0.0)
.expect("build handle");
let via_handle = system
.generate_ephemeris(&ctx, &orbits, &observers, &eph_cfg)
.expect("handle ephemeris");
assert_eq!(one_shot.entries.len(), via_handle.entries.len());
assert!(!one_shot.entries.is_empty(), "expected at least one entry");
for (i, (a, b)) in one_shot
.entries
.iter()
.zip(via_handle.entries.iter())
.enumerate()
{
assert_eq!(a.ra_deg, b.ra_deg, "ra[{i}] bit-identical");
assert_eq!(a.dec_deg, b.dec_deg, "dec[{i}] bit-identical");
assert_eq!(a.rho_au, b.rho_au, "rho[{i}] bit-identical");
}
}
#[test]
fn builtsystem_describe_reports_provenance() {
let ctx = match try_ctx() {
Some(c) => c,
None => {
eprintln!("skipping builtsystem_describe_reports_provenance: no data dir");
return;
}
};
let system = ctx
.built_system(ForceModelTier::Standard, Frame::ICRF, 0.0)
.expect("build handle");
let desc = system.describe().expect("describe");
assert_eq!(desc.force_model, ForceModelTier::Standard);
assert_eq!(desc.frame, Frame::ICRF);
assert_eq!(
desc.encounter_timescale_divisor, 1000.0,
"engine default divisor"
);
assert!(desc.relativistic, "Standard tier includes GR");
assert!(desc.asteroids, "Standard tier includes N16 asteroids");
assert!(desc.has_bpc, "Standard tier loads a BPC");
assert!(
!desc.perturber_origins.is_empty(),
"Standard tier has perturbers"
);
assert!(!desc.kernels.is_empty(), "kernel manifest is non-empty");
let mut checked_a_file = false;
let mut smallest: Option<(&PathBuf, &String, u64)> = None;
for rec in &desc.kernels {
if let KernelProvenance::File {
path,
sha256,
bytes,
} = &rec.provenance
{
assert_eq!(sha256.len(), 64, "sha256 is 64 chars");
assert!(
sha256.chars().all(|c| c.is_ascii_hexdigit()),
"sha256 is lowercase hex: {sha256}"
);
assert!(*bytes > 0, "FILE record has a nonzero byte count");
if smallest
.as_ref()
.map(|(_, _, b)| *bytes < *b)
.unwrap_or(true)
{
smallest = Some((path, sha256, *bytes));
}
checked_a_file = true;
}
}
assert!(
checked_a_file,
"expected at least one FILE-provenance kernel"
);
let (path, _sha, bytes) = smallest.unwrap();
let on_disk = std::fs::metadata(path).expect("kernel file exists").len();
assert_eq!(on_disk, bytes, "manifest byte count matches file on disk");
}
#[test]
fn builtsystem_guard_fires_on_key_mismatch() {
let ctx = match try_ctx() {
Some(c) => c,
None => {
eprintln!("skipping builtsystem_guard_fires_on_key_mismatch: no data dir");
return;
}
};
let system = ctx
.built_system(ForceModelTier::Standard, Frame::ICRF, 0.0)
.expect("build handle");
let orbits = [base_orbit()];
let epochs = [Epoch::from_mjd_tdb(59000.0), Epoch::from_mjd_tdb(59010.0)];
let cfg_fm = PropagationConfig {
force_model: ForceModelTier::Basic,
frame: Frame::ICRF,
..PropagationConfig::default()
};
let err_fm = system
.propagate(&ctx, &orbits, &epochs, &cfg_fm)
.expect_err("force-model mismatch must error");
assert_eq!(
err_fm.builtsystem_guard(),
Some(BuiltSystemGuardError::KeyMismatchForceModel),
"force-model mismatch fires by axis: {err_fm}"
);
let cfg_fr = PropagationConfig {
force_model: ForceModelTier::Standard,
frame: Frame::EclipticJ2000,
..PropagationConfig::default()
};
let err_fr = system
.propagate(&ctx, &orbits, &epochs, &cfg_fr)
.expect_err("frame mismatch must error");
assert_eq!(
err_fr.builtsystem_guard(),
Some(BuiltSystemGuardError::KeyMismatchFrame),
"frame mismatch fires by axis: {err_fr}"
);
}
#[test]
fn builtsystem_guard_fires_on_data_mismatch() {
let ctx_a = match try_ctx() {
Some(c) => c,
None => {
eprintln!("skipping builtsystem_guard_fires_on_data_mismatch: no data dir");
return;
}
};
let ctx_b = match try_ctx() {
Some(c) => c,
None => {
eprintln!("skipping builtsystem_guard_fires_on_data_mismatch: second context");
return;
}
};
let system = ctx_a
.built_system(ForceModelTier::Standard, Frame::ICRF, 0.0)
.expect("build handle");
let orbits = [base_orbit()];
let epochs = [Epoch::from_mjd_tdb(59000.0), Epoch::from_mjd_tdb(59010.0)];
let cfg = standard_icrf_config();
let err = system
.propagate(&ctx_b, &orbits, &epochs, &cfg)
.expect_err("foreign context must be rejected");
assert_eq!(
err.builtsystem_guard(),
Some(BuiltSystemGuardError::DataMismatch),
"foreign context fires the data-identity guard: {err}"
);
system
.propagate(&ctx_a, &orbits, &epochs, &cfg)
.expect("correct context must pass");
}
}