Skip to main content

native/sidereon_nif/src/oem.rs

//! Rustler boundary for the CCSDS OEM KVN and XML reader/writer.
//!
//! Pure glue over `sidereon_core::astro::oem`: decode the raw KVN/XML text or the
//! normalized field map, forward to the crate codec, and encode the unchanged
//! Sidereon result shapes. No grammar, unit handling, or number formatting lives
//! here. Date/time fields cross as raw strings; the Elixir binding owns any
//! resolution to its native `DateTime`. Failure categories cross as atoms.

use rustler::{Encoder, Env, Term};
use sidereon_core::astro::covariance::{Covariance6, Mat6};
use sidereon_core::astro::oem::{
    self as core_oem, Oem, OemCovariance, OemError, OemMetadata, OemSegment, OemState,
};

mod atoms {
    rustler::atoms! {
        ok,
        error,
        missing_field,
        invalid_field,
        malformed
    }
}

/// One Cartesian state sample exchanged with the Elixir binding. Mirrors
/// `%Sidereon.CCSDS.OEM.State{}` with position/velocity as `{x, y, z}` tuples and
/// an optional acceleration tuple.
#[derive(Debug, Clone, rustler::NifMap)]
struct OemStateFields {
    epoch: String,
    position_km: (f64, f64, f64),
    velocity_km_s: (f64, f64, f64),
    acceleration_km_s2: Option<(f64, f64, f64)>,
}

/// One covariance block exchanged with the Elixir binding. The 6x6 matrix is a
/// row-major list of six six-element rows.
#[derive(Debug, Clone, rustler::NifMap)]
struct OemCovarianceFields {
    epoch: String,
    cov_ref_frame: Option<String>,
    matrix: Vec<Vec<f64>>,
}

/// Segment metadata exchanged with the Elixir binding.
#[derive(Debug, Clone, rustler::NifMap)]
struct OemMetadataFields {
    object_name: String,
    object_id: String,
    center_name: String,
    ref_frame: String,
    time_system: String,
    start_time: String,
    stop_time: String,
    useable_start_time: Option<String>,
    useable_stop_time: Option<String>,
    interpolation: Option<String>,
    interpolation_degree: Option<i64>,
}

/// One metadata/data segment exchanged with the Elixir binding.
#[derive(Debug, Clone, rustler::NifMap)]
struct OemSegmentFields {
    metadata: OemMetadataFields,
    states: Vec<OemStateFields>,
    covariances: Vec<OemCovarianceFields>,
}

/// Normalized OEM fields exchanged with the Elixir binding, mirroring
/// `%Sidereon.CCSDS.OEM{}`.
#[derive(Debug, Clone, rustler::NifMap)]
struct OemFields {
    ccsds_oem_vers: String,
    creation_date: Option<String>,
    originator: Option<String>,
    segments: Vec<OemSegmentFields>,
    skipped_states: i64,
}

fn vec3((x, y, z): (f64, f64, f64)) -> [f64; 3] {
    [x, y, z]
}

fn tuple3(v: [f64; 3]) -> (f64, f64, f64) {
    (v[0], v[1], v[2])
}

fn matrix_rows(matrix: &Mat6) -> Vec<Vec<f64>> {
    matrix.iter().map(|row| row.to_vec()).collect()
}

/// Rebuild a 6x6 covariance from the row-major list. Missing or short rows leave
/// the remaining cells at zero; the round-trip carries trusted parsed data, so
/// the matrix is wrapped without re-validation.
fn covariance_from_rows(rows: &[Vec<f64>]) -> Covariance6 {
    let mut matrix: Mat6 = [[0.0_f64; 6]; 6];
    for (out_row, in_row) in matrix.iter_mut().zip(rows) {
        for (slot, value) in out_row.iter_mut().zip(in_row) {
            *slot = *value;
        }
    }
    Covariance6::from_matrix_unchecked(matrix)
}

impl From<OemState> for OemStateFields {
    fn from(s: OemState) -> Self {
        Self {
            epoch: s.epoch,
            position_km: tuple3(s.position_km),
            velocity_km_s: tuple3(s.velocity_km_s),
            acceleration_km_s2: s.acceleration_km_s2.map(tuple3),
        }
    }
}

impl From<OemStateFields> for OemState {
    fn from(f: OemStateFields) -> Self {
        Self {
            epoch: f.epoch,
            position_km: vec3(f.position_km),
            velocity_km_s: vec3(f.velocity_km_s),
            acceleration_km_s2: f.acceleration_km_s2.map(vec3),
        }
    }
}

impl From<OemCovariance> for OemCovarianceFields {
    fn from(c: OemCovariance) -> Self {
        Self {
            epoch: c.epoch,
            cov_ref_frame: c.cov_ref_frame,
            matrix: matrix_rows(c.matrix.as_matrix()),
        }
    }
}

impl From<OemCovarianceFields> for OemCovariance {
    fn from(f: OemCovarianceFields) -> Self {
        Self {
            epoch: f.epoch,
            cov_ref_frame: f.cov_ref_frame,
            matrix: covariance_from_rows(&f.matrix),
        }
    }
}

impl From<OemMetadata> for OemMetadataFields {
    fn from(m: OemMetadata) -> Self {
        Self {
            object_name: m.object_name,
            object_id: m.object_id,
            center_name: m.center_name,
            ref_frame: m.ref_frame,
            time_system: m.time_system,
            start_time: m.start_time,
            stop_time: m.stop_time,
            useable_start_time: m.useable_start_time,
            useable_stop_time: m.useable_stop_time,
            interpolation: m.interpolation,
            interpolation_degree: m.interpolation_degree.map(|d| d as i64),
        }
    }
}

impl From<OemMetadataFields> for OemMetadata {
    fn from(f: OemMetadataFields) -> Self {
        Self {
            object_name: f.object_name,
            object_id: f.object_id,
            center_name: f.center_name,
            ref_frame: f.ref_frame,
            time_system: f.time_system,
            start_time: f.start_time,
            stop_time: f.stop_time,
            useable_start_time: f.useable_start_time,
            useable_stop_time: f.useable_stop_time,
            interpolation: f.interpolation,
            interpolation_degree: f.interpolation_degree.map(|d| d as u32),
        }
    }
}

impl From<OemSegment> for OemSegmentFields {
    fn from(s: OemSegment) -> Self {
        Self {
            metadata: s.metadata.into(),
            states: s.states.into_iter().map(Into::into).collect(),
            covariances: s.covariances.into_iter().map(Into::into).collect(),
        }
    }
}

impl From<OemSegmentFields> for OemSegment {
    fn from(f: OemSegmentFields) -> Self {
        Self {
            metadata: f.metadata.into(),
            states: f.states.into_iter().map(Into::into).collect(),
            covariances: f.covariances.into_iter().map(Into::into).collect(),
        }
    }
}

impl From<Oem> for OemFields {
    fn from(o: Oem) -> Self {
        Self {
            ccsds_oem_vers: o.ccsds_oem_vers,
            creation_date: o.creation_date,
            originator: o.originator,
            segments: o.segments.into_iter().map(Into::into).collect(),
            skipped_states: o.skipped_states as i64,
        }
    }
}

impl From<OemFields> for Oem {
    fn from(f: OemFields) -> Self {
        Self {
            ccsds_oem_vers: f.ccsds_oem_vers,
            creation_date: f.creation_date,
            originator: f.originator,
            segments: f.segments.into_iter().map(Into::into).collect(),
            skipped_states: f.skipped_states.max(0) as usize,
        }
    }
}

/// Map a core OEM failure to its category atom, so the Elixir caller sees a
/// `{:error, atom}` reason rather than a leaked Rust string.
fn error_atom(error: &OemError) -> rustler::Atom {
    match error {
        OemError::MissingField(_) => atoms::missing_field(),
        OemError::InvalidField { .. } => atoms::invalid_field(),
        OemError::Field(_) => atoms::malformed(),
    }
}

fn parse_result<'a>(env: Env<'a>, result: Result<Oem, OemError>) -> Term<'a> {
    match result {
        Ok(parsed) => (atoms::ok(), OemFields::from(parsed)).encode(env),
        Err(e) => (atoms::error(), error_atom(&e)).encode(env),
    }
}

/// Parse a CCSDS OEM in KVN encoding.
#[rustler::nif(schedule = "DirtyCpu")]
fn oem_parse_kvn<'a>(env: Env<'a>, text: String) -> Term<'a> {
    parse_result(env, core_oem::parse_kvn(&text))
}

/// Parse a CCSDS OEM in XML encoding.
#[rustler::nif(schedule = "DirtyCpu")]
fn oem_parse_xml<'a>(env: Env<'a>, text: String) -> Term<'a> {
    parse_result(env, core_oem::parse_xml(&text))
}

/// Serialize normalized OEM fields as CCSDS OEM KVN text.
#[rustler::nif(schedule = "DirtyCpu")]
fn oem_encode_kvn(fields: OemFields) -> String {
    core_oem::encode_kvn(&fields.into())
}

/// Serialize normalized OEM fields as CCSDS OEM XML text.
#[rustler::nif(schedule = "DirtyCpu")]
fn oem_encode_xml(fields: OemFields) -> String {
    core_oem::encode_xml(&fields.into())
}