Skip to main content

native/sidereon_nif/src/opm.rs

//! Rustler boundary for the CCSDS OPM KVN and XML reader/writer.
//!
//! Pure glue over `sidereon_core::astro::opm`: 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`. The optional true/mean anomaly crosses as
//! an `anomaly_kind` tag (`"TRUE"`/`"MEAN"`) plus an `anomaly_deg` value. Failure
//! categories cross as atoms.

use rustler::{Encoder, Env, Term};
use sidereon_core::astro::covariance::{Covariance6, Mat6};
use sidereon_core::astro::opm::{
    self as core_opm, Opm, OpmAnomaly, OpmCovariance, OpmError, OpmKeplerian, OpmManeuver,
    OpmMetadata, OpmSpacecraft, OpmState,
};

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

const ANOMALY_TRUE: &str = "TRUE";
const ANOMALY_MEAN: &str = "MEAN";

#[derive(Debug, Clone, rustler::NifMap)]
struct OpmMetadataFields {
    object_name: String,
    object_id: String,
    center_name: String,
    ref_frame: String,
    time_system: String,
}

#[derive(Debug, Clone, rustler::NifMap)]
struct OpmStateFields {
    epoch: String,
    position_km: (f64, f64, f64),
    velocity_km_s: (f64, f64, f64),
}

#[derive(Debug, Clone, rustler::NifMap)]
struct OpmKeplerianFields {
    semi_major_axis_km: f64,
    eccentricity: f64,
    inclination_deg: f64,
    ra_of_asc_node_deg: f64,
    arg_of_pericenter_deg: f64,
    anomaly_kind: String,
    anomaly_deg: f64,
    gm_km3_s2: f64,
}

#[derive(Debug, Clone, rustler::NifMap)]
struct OpmSpacecraftFields {
    mass_kg: Option<f64>,
    solar_rad_area_m2: Option<f64>,
    solar_rad_coeff: Option<f64>,
    drag_area_m2: Option<f64>,
    drag_coeff: Option<f64>,
}

#[derive(Debug, Clone, rustler::NifMap)]
struct OpmCovarianceFields {
    cov_ref_frame: Option<String>,
    matrix: Vec<Vec<f64>>,
}

#[derive(Debug, Clone, rustler::NifMap)]
struct OpmManeuverFields {
    epoch_ignition: String,
    duration_s: f64,
    delta_mass_kg: f64,
    ref_frame: String,
    dv_km_s: (f64, f64, f64),
}

#[derive(Debug, Clone, rustler::NifMap)]
struct OpmFields {
    ccsds_opm_vers: String,
    creation_date: Option<String>,
    originator: Option<String>,
    metadata: OpmMetadataFields,
    state: OpmStateFields,
    keplerian: Option<OpmKeplerianFields>,
    spacecraft: Option<OpmSpacecraftFields>,
    covariance: Option<OpmCovarianceFields>,
    maneuvers: Vec<OpmManeuverFields>,
}

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()
}

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<OpmMetadata> for OpmMetadataFields {
    fn from(m: OpmMetadata) -> 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,
        }
    }
}

impl From<OpmMetadataFields> for OpmMetadata {
    fn from(f: OpmMetadataFields) -> 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,
        }
    }
}

impl From<OpmState> for OpmStateFields {
    fn from(s: OpmState) -> Self {
        Self {
            epoch: s.epoch,
            position_km: tuple3(s.position_km),
            velocity_km_s: tuple3(s.velocity_km_s),
        }
    }
}

impl From<OpmStateFields> for OpmState {
    fn from(f: OpmStateFields) -> Self {
        Self {
            epoch: f.epoch,
            position_km: vec3(f.position_km),
            velocity_km_s: vec3(f.velocity_km_s),
        }
    }
}

impl From<OpmKeplerian> for OpmKeplerianFields {
    fn from(k: OpmKeplerian) -> Self {
        let (anomaly_kind, anomaly_deg) = match k.anomaly {
            OpmAnomaly::True(v) => (ANOMALY_TRUE.to_string(), v),
            OpmAnomaly::Mean(v) => (ANOMALY_MEAN.to_string(), v),
        };
        Self {
            semi_major_axis_km: k.semi_major_axis_km,
            eccentricity: k.eccentricity,
            inclination_deg: k.inclination_deg,
            ra_of_asc_node_deg: k.ra_of_asc_node_deg,
            arg_of_pericenter_deg: k.arg_of_pericenter_deg,
            anomaly_kind,
            anomaly_deg,
            gm_km3_s2: k.gm_km3_s2,
        }
    }
}

/// Rebuild the anomaly enum from the `anomaly_kind` tag. Any tag other than the
/// mean marker is treated as a true anomaly, matching the CCSDS default where the
/// `TRUE_ANOMALY` keyword is the canonical form.
fn anomaly_from_fields(kind: &str, deg: f64) -> OpmAnomaly {
    if kind.eq_ignore_ascii_case(ANOMALY_MEAN) {
        OpmAnomaly::Mean(deg)
    } else {
        OpmAnomaly::True(deg)
    }
}

impl From<OpmKeplerianFields> for OpmKeplerian {
    fn from(f: OpmKeplerianFields) -> Self {
        let anomaly = anomaly_from_fields(&f.anomaly_kind, f.anomaly_deg);
        Self {
            semi_major_axis_km: f.semi_major_axis_km,
            eccentricity: f.eccentricity,
            inclination_deg: f.inclination_deg,
            ra_of_asc_node_deg: f.ra_of_asc_node_deg,
            arg_of_pericenter_deg: f.arg_of_pericenter_deg,
            anomaly,
            gm_km3_s2: f.gm_km3_s2,
        }
    }
}

impl From<OpmSpacecraft> for OpmSpacecraftFields {
    fn from(s: OpmSpacecraft) -> Self {
        Self {
            mass_kg: s.mass_kg,
            solar_rad_area_m2: s.solar_rad_area_m2,
            solar_rad_coeff: s.solar_rad_coeff,
            drag_area_m2: s.drag_area_m2,
            drag_coeff: s.drag_coeff,
        }
    }
}

impl From<OpmSpacecraftFields> for OpmSpacecraft {
    fn from(f: OpmSpacecraftFields) -> Self {
        Self {
            mass_kg: f.mass_kg,
            solar_rad_area_m2: f.solar_rad_area_m2,
            solar_rad_coeff: f.solar_rad_coeff,
            drag_area_m2: f.drag_area_m2,
            drag_coeff: f.drag_coeff,
        }
    }
}

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

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

impl From<OpmManeuver> for OpmManeuverFields {
    fn from(m: OpmManeuver) -> Self {
        Self {
            epoch_ignition: m.epoch_ignition,
            duration_s: m.duration_s,
            delta_mass_kg: m.delta_mass_kg,
            ref_frame: m.ref_frame,
            dv_km_s: tuple3(m.dv_km_s),
        }
    }
}

impl From<OpmManeuverFields> for OpmManeuver {
    fn from(f: OpmManeuverFields) -> Self {
        Self {
            epoch_ignition: f.epoch_ignition,
            duration_s: f.duration_s,
            delta_mass_kg: f.delta_mass_kg,
            ref_frame: f.ref_frame,
            dv_km_s: vec3(f.dv_km_s),
        }
    }
}

impl From<Opm> for OpmFields {
    fn from(o: Opm) -> Self {
        Self {
            ccsds_opm_vers: o.ccsds_opm_vers,
            creation_date: o.creation_date,
            originator: o.originator,
            metadata: o.metadata.into(),
            state: o.state.into(),
            keplerian: o.keplerian.map(Into::into),
            spacecraft: o.spacecraft.map(Into::into),
            covariance: o.covariance.map(Into::into),
            maneuvers: o.maneuvers.into_iter().map(Into::into).collect(),
        }
    }
}

impl From<OpmFields> for Opm {
    fn from(f: OpmFields) -> Self {
        Self {
            ccsds_opm_vers: f.ccsds_opm_vers,
            creation_date: f.creation_date,
            originator: f.originator,
            metadata: f.metadata.into(),
            state: f.state.into(),
            keplerian: f.keplerian.map(Into::into),
            spacecraft: f.spacecraft.map(Into::into),
            covariance: f.covariance.map(Into::into),
            maneuvers: f.maneuvers.into_iter().map(Into::into).collect(),
        }
    }
}

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

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

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

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

/// Serialize normalized OPM fields as CCSDS OPM KVN text.
#[rustler::nif(schedule = "DirtyCpu")]
fn opm_encode_kvn(fields: OpmFields) -> String {
    core_opm::encode_kvn(&fields.into())
}

/// Serialize normalized OPM fields as CCSDS OPM XML text.
#[rustler::nif(schedule = "DirtyCpu")]
fn opm_encode_xml(fields: OpmFields) -> String {
    core_opm::encode_xml(&fields.into())
}