Skip to main content

native/sidereon_nif/src/broadcast.rs

//! Rustler boundary for the `sidereon-core` broadcast-navigation product.
//!
//! Pure glue: `broadcast_parse/1` decodes RINEX navigation text, calls
//! [`BroadcastEphemeris::from_nav`], and returns a resource handle holding the
//! parsed records. `broadcast_position/4` evaluates one satellite's orbit and
//! clock at an instant via the crate's [`EphemerisSource`] contract; the
//! single-point-positioning solve consumes the same handle via
//! `spp::spp_solve_broadcast/15`. No parsing grammar or orbit math lives here.

use rustler::{Encoder, Env, Error, NifResult, ResourceArc, Term};
use sidereon_core::ephemeris::{BroadcastEphemeris, EphemerisSource};
use sidereon_core::rinex::nav::{encode_nav, parse_leap_seconds, KlobucharAlphaBeta, NavMessage};
use sidereon_core::{GnssSatelliteId, GnssSystem};

/// Resource handle holding a parsed broadcast-navigation product across calls.
pub struct BroadcastResource {
    pub store: BroadcastEphemeris,
    pub leap_seconds: Option<f64>,
}

type Vec3Tuple = (f64, f64, f64);
type ElementsList = Vec<f64>;
type ClockTuple = (f64, f64, f64, f64);
type RecordMetaTuple = (f64, f64, f64, Option<f64>);
type BroadcastRecordTuple = (
    String,
    &'static str,
    u32,
    ElementsList,
    ClockTuple,
    RecordMetaTuple,
);
type GlonassMetaTuple = (f64, f64, f64, i32);
type GlonassRecordTuple = (
    String,
    f64,
    Vec3Tuple,
    Vec3Tuple,
    Vec3Tuple,
    GlonassMetaTuple,
);
type KlobucharTuple = (Vec<f64>, Vec<f64>);

#[rustler::resource_impl]
impl rustler::Resource for BroadcastResource {}

/// Map a GNSS single-letter system identifier (e.g. `"G"`) onto the crate's
/// [`GnssSystem`]. Pure identifier translation; mirrors `sp3::system_from_letter`.
fn system_from_letter(letter: &str) -> NifResult<GnssSystem> {
    let c = letter
        .chars()
        .next()
        .ok_or_else(|| Error::Term(Box::new("empty GNSS system letter")))?;
    GnssSystem::from_letter(c)
        .ok_or_else(|| Error::Term(Box::new(format!("unknown GNSS system letter {letter:?}"))))
}

fn nav_message_label(message: NavMessage) -> &'static str {
    match message {
        NavMessage::GpsLnav => "gps_lnav",
        NavMessage::GalileoInav => "galileo_inav",
        NavMessage::GalileoFnav => "galileo_fnav",
        NavMessage::BeidouD1 => "beidou_d1",
        NavMessage::BeidouD2 => "beidou_d2",
    }
}

fn alpha_beta_tuple(coeffs: KlobucharAlphaBeta) -> KlobucharTuple {
    (coeffs.alpha.to_vec(), coeffs.beta.to_vec())
}

/// Parse RINEX 3.x/4.xx navigation text into a broadcast-ephemeris resource handle.
///
/// Dirty-CPU: parsing a full daily multi-GNSS file is unbounded relative to the
/// 1 ms NIF budget. On a malformed file returns the parser's error as a term.
#[rustler::nif(schedule = "DirtyCpu")]
fn broadcast_parse(text: String) -> NifResult<ResourceArc<BroadcastResource>> {
    let store =
        BroadcastEphemeris::from_nav(&text).map_err(|e| Error::Term(Box::new(e.to_string())))?;
    let leap_seconds = parse_leap_seconds(&text).map_err(crate::errors::invalid_input)?;
    Ok(ResourceArc::new(BroadcastResource {
        store,
        leap_seconds,
    }))
}

/// Number of usable GPS, Galileo, and BeiDou records held by the parsed product.
#[rustler::nif]
fn broadcast_record_count(handle: ResourceArc<BroadcastResource>) -> u64 {
    handle.store.records().len() as u64
}

/// Serialize the held GPS/Galileo/BeiDou broadcast records to RINEX 3
/// navigation text. Pure delegation to `rinex::nav::encode_nav` over the records
/// the store already holds; re-parsing the output reconstructs the same records.
/// No serialization grammar lives here.
#[rustler::nif(schedule = "DirtyCpu")]
fn broadcast_encode_nav(handle: ResourceArc<BroadcastResource>) -> String {
    encode_nav(handle.store.records())
}

/// Number of healthy GLONASS state-vector records held by the parsed product.
#[rustler::nif]
fn broadcast_glonass_record_count(handle: ResourceArc<BroadcastResource>) -> u64 {
    handle.store.glonass_records().len() as u64
}

/// Usable GPS, Galileo, and BeiDou broadcast records in file order.
#[rustler::nif]
fn broadcast_records(handle: ResourceArc<BroadcastResource>) -> Vec<BroadcastRecordTuple> {
    handle
        .store
        .records()
        .iter()
        .map(|record| {
            let elements = record.elements;
            let clock = record.clock;
            (
                record.satellite_id.to_string(),
                nav_message_label(record.message),
                record.week,
                vec![
                    elements.sqrt_a,
                    elements.e,
                    elements.m0,
                    elements.delta_n,
                    elements.omega0,
                    elements.i0,
                    elements.omega,
                    elements.omega_dot,
                    elements.idot,
                    elements.cuc,
                    elements.cus,
                    elements.crc,
                    elements.crs,
                    elements.cic,
                    elements.cis,
                    elements.toe_sow,
                ],
                (clock.af0, clock.af1, clock.af2, clock.toc_sow),
                (
                    record.broadcast_clock_group_delay_s(),
                    record.sv_health,
                    record.sv_accuracy_m,
                    record.fit_interval_s,
                ),
            )
        })
        .collect()
}

/// Healthy GLONASS state-vector broadcast records in file order.
#[rustler::nif]
fn broadcast_glonass_records(handle: ResourceArc<BroadcastResource>) -> Vec<GlonassRecordTuple> {
    handle
        .store
        .glonass_records()
        .iter()
        .map(|record| {
            (
                record.satellite_id.to_string(),
                record.toe_utc_j2000_s,
                (record.pos_m[0], record.pos_m[1], record.pos_m[2]),
                (record.vel_m_s[0], record.vel_m_s[1], record.vel_m_s[2]),
                (record.acc_m_s2[0], record.acc_m_s2[1], record.acc_m_s2[2]),
                (
                    record.clk_bias,
                    record.gamma_n,
                    record.sv_health,
                    record.freq_channel,
                ),
            )
        })
        .collect()
}

/// Broadcast ionosphere coefficients parsed from the NAV header.
#[rustler::nif]
fn broadcast_iono_corrections(
    handle: ResourceArc<BroadcastResource>,
) -> (Option<KlobucharTuple>, Option<KlobucharTuple>) {
    let iono = handle.store.iono_corrections();
    (
        iono.gps.map(alpha_beta_tuple),
        iono.beidou.map(alpha_beta_tuple),
    )
}

/// GPS minus UTC leap seconds from the NAV header, if present.
#[rustler::nif]
fn broadcast_leap_seconds(handle: ResourceArc<BroadcastResource>) -> Option<f64> {
    handle.leap_seconds
}

/// Evaluate `sat`'s broadcast orbit and clock at `t_j2000_s` against a loaded
/// handle.
///
/// `t_j2000_s` is the query instant as a continuous second-of-J2000 in the
/// GPST-aligned scale the crate's [`EphemerisSource`] contract expects (it maps
/// that onto each system's own time — BDT for BeiDou, UTC-referenced for GLONASS
/// — internally). Returns `{x_m, y_m, z_m, clock_s}` — ECEF meters and the
/// satellite clock offset in seconds — or the atom `nil` when the product has no
/// usable ephemeris for that satellite at that instant (the crate returns
/// `None`). The miss is encoded as an atom rather than a tuple of NaNs, which the
/// BEAM cannot represent. Pure glue over
/// [`EphemerisSource::position_clock_at_j2000_s`]; no orbit math or file I/O
/// lives here.
#[rustler::nif]
fn broadcast_position<'a>(
    env: Env<'a>,
    handle: ResourceArc<BroadcastResource>,
    system_letter: String,
    prn: u8,
    t_j2000_s: f64,
) -> NifResult<Term<'a>> {
    let system = system_from_letter(&system_letter)?;
    let sat = GnssSatelliteId::new(system, prn).map_err(crate::errors::invalid_input)?;

    match handle.store.position_clock_at_j2000_s(sat, t_j2000_s) {
        Some(([x_m, y_m, z_m], clock_s)) => Ok((x_m, y_m, z_m, clock_s).encode(env)),
        None => Ok(rustler::types::atom::nil().encode(env)),
    }
}