Skip to main content

native/sidereon_nif/src/rinex_obs.rs

//! Rustler boundary for the `sidereon-core` RINEX 3 observation product
//! and Hatanaka (CRINEX) decoder.
//!
//! Pure glue: it decodes Erlang terms, calls the crate's `rinex` public APIs,
//! holds the parsed product as a resource handle, and
//! encodes results back. No CRINEX grammar, RINEX parsing, or pseudorange
//! selection numerics live here — those are the crate's responsibility.
//!
//! - `crinex_decode/1` expands CRINEX text to plain RINEX text.
//! - `rinex_obs_parse/1` parses plain RINEX observation text into a handle.
//! - `crinex_obs_parse/1` decodes CRINEX then parses, in one dirty call, so a
//!   multi-megabyte expanded RINEX string is consumed inside Rust rather than
//!   marshalled across the BEAM boundary only to be passed straight back.
//! - the accessors expose the header, the epoch list, and per-epoch
//!   single-frequency pseudoranges as the `[{sat_token, range_m}]` shape the
//!   point-positioning solver consumes.

use rustler::{Encoder, Env, Error, NifResult, ResourceArc, Term};
use sidereon_core::frequencies::rinex_band_frequency_hz;
use sidereon_core::rinex::{
    decode_crinex, encode_crinex,
    observations::{
        carrier_phase_rows, observation_values, pseudoranges, ObsEpoch, ObservationFilter,
        RinexObs, SignalPolicy,
    },
};
use sidereon_core::GnssSystem;

mod atoms {
    rustler::atoms! {
        invalid_input
    }
}

/// Resource handle holding a parsed RINEX observation product across NIF calls.
pub struct RinexObsResource {
    pub obs: RinexObs,
}

/// One labelled observation crossing the boundary: code, value (`nil` if blank),
/// loss-of-lock indicator, signal-strength indicator.
type ObsValueRow = (
    String,
    &'static str,
    &'static str,
    Option<f64>,
    Option<u8>,
    Option<u8>,
);
/// A satellite token paired with its labelled observation values.
type SatObsRow = (String, Vec<ObsValueRow>);
/// Frequency, wavelength, meter-valued phase, and phase-shift correction.
type PhaseMeta = (Option<f64>, Option<f64>, Option<f64>, f64);
/// One carrier-phase observation with derived frequency/wavelength/metres.
type PhaseRow = (String, Option<f64>, Option<u8>, Option<u8>, PhaseMeta);
/// A satellite token paired with its carrier-phase observations.
type SatPhaseRow = (String, Vec<PhaseRow>);

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

/// Decode CRINEX (Hatanaka) text into the plain RINEX observation text it
/// expands to.
///
/// Dirty-CPU: a daily file's expansion is unbounded relative to the 1 ms NIF
/// budget. Returns the decoded String, or the crate's parse-error reason.
#[rustler::nif(schedule = "DirtyCpu")]
fn crinex_decode(text: String) -> NifResult<String> {
    decode_crinex(&text).map_err(|e| Error::Term(Box::new(e.to_string())))
}

/// Encode plain RINEX observation text into a CRINEX (Hatanaka) stream, the
/// inverse of `crinex_decode/1`.
///
/// Dirty-CPU: a daily file's compression is unbounded relative to the 1 ms NIF
/// budget. Returns the CRINEX String, or the crate's parse-error reason for
/// malformed RINEX input.
#[rustler::nif(schedule = "DirtyCpu")]
fn crinex_encode(text: String) -> NifResult<String> {
    encode_crinex(&text).map_err(|e| Error::Term(Box::new(e.to_string())))
}

/// Parse plain RINEX 3 observation text into a resource handle.
///
/// Dirty-CPU: parsing a full daily file is unbounded relative to the NIF
/// budget. On a malformed file returns the parser's error as a term.
#[rustler::nif(schedule = "DirtyCpu")]
fn rinex_obs_parse(text: String) -> NifResult<ResourceArc<RinexObsResource>> {
    let obs = RinexObs::parse(&text).map_err(|e| Error::Term(Box::new(e.to_string())))?;
    Ok(ResourceArc::new(RinexObsResource { obs }))
}

/// Decode CRINEX text and parse the result in one dirty call.
///
/// The expanded RINEX text is consumed inside Rust, so only the compact typed
/// handle crosses back to the BEAM (the expansion is never marshalled).
#[rustler::nif(schedule = "DirtyCpu")]
fn crinex_obs_parse(text: String) -> NifResult<ResourceArc<RinexObsResource>> {
    let decoded = decode_crinex(&text).map_err(|e| Error::Term(Box::new(e.to_string())))?;
    let obs = RinexObs::parse(&decoded).map_err(|e| Error::Term(Box::new(e.to_string())))?;
    Ok(ResourceArc::new(RinexObsResource { obs }))
}

/// Serialize a parsed RINEX observation product back to standard RINEX 3
/// observation text. The inverse of `rinex_obs_parse`: re-parsing the output
/// reproduces the same header and epochs. Dirty-CPU because a daily file's
/// serialization is unbounded relative to the NIF budget.
#[rustler::nif(schedule = "DirtyCpu")]
fn rinex_obs_to_string(handle: ResourceArc<RinexObsResource>) -> String {
    handle.obs.to_rinex_string()
}

/// The surveyed a-priori receiver position `{x_m, y_m, z_m}` (ECEF meters), or
/// the atom `nil` when the file carries no `APPROX POSITION XYZ`.
#[rustler::nif]
fn rinex_obs_approx_position(env: Env<'_>, handle: ResourceArc<RinexObsResource>) -> Term<'_> {
    match handle.obs.header.approx_position_m {
        Some([x, y, z]) => (x, y, z).encode(env),
        None => rustler::types::atom::nil().encode(env),
    }
}

/// The antenna reference-point offset from the marker `{h_m, e_m, n_m}`, or
/// the atom `nil` when the file carries no `ANTENNA: DELTA H/E/N`.
#[rustler::nif]
fn rinex_obs_antenna_delta_hen(env: Env<'_>, handle: ResourceArc<RinexObsResource>) -> Term<'_> {
    match handle.obs.header.antenna_delta_hen_m {
        Some([h, e, n]) => (h, e, n).encode(env),
        None => rustler::types::atom::nil().encode(env),
    }
}

/// Carrier phase-shift header records as
/// `[{"G", "L1C", correction_cycles, ["G01", ...]}, ...]`.
#[rustler::nif]
fn rinex_obs_phase_shifts(
    handle: ResourceArc<RinexObsResource>,
) -> Vec<(String, String, f64, Vec<String>)> {
    handle
        .obs
        .header
        .phase_shifts
        .iter()
        .map(|shift| {
            (
                shift.system.letter().to_string(),
                shift.code.clone(),
                shift.correction_cycles,
                shift.satellites.iter().map(ToString::to_string).collect(),
            )
        })
        .collect()
}

/// The per-constellation observation-code table as `[{"G", ["C1C", ...]}, ...]`
/// in declared order (system letter, then the code list).
#[rustler::nif]
fn rinex_obs_codes(handle: ResourceArc<RinexObsResource>) -> Vec<(String, Vec<String>)> {
    handle
        .obs
        .header
        .obs_codes
        .iter()
        .map(|(sys, codes)| (sys.letter().to_string(), codes.clone()))
        .collect()
}

/// The GLONASS satellite slot/frequency-channel map from the optional
/// `GLONASS SLOT / FRQ #` header records, as `[{"R01", +1}, ...]`.
#[rustler::nif]
fn rinex_obs_glonass_slots(handle: ResourceArc<RinexObsResource>) -> Vec<(String, i8)> {
    handle
        .obs
        .header
        .glonass_slots
        .iter()
        .map(|(slot, channel)| (format!("R{slot:02}"), *channel))
        .collect()
}

/// The number of parsed epochs.
#[rustler::nif]
fn rinex_obs_epoch_count(handle: ResourceArc<RinexObsResource>) -> usize {
    handle.obs.epochs.len()
}

/// The epoch list as `[{ {{y,mo,d},{h,mi,second_float}}, flag, sat_count }]`, so
/// Elixir can index/select epochs without pulling every observation across the
/// boundary. The civil-time tuple is exactly the form `solve/4` accepts.
#[rustler::nif]
fn rinex_obs_epochs(env: Env<'_>, handle: ResourceArc<RinexObsResource>) -> Term<'_> {
    let list: Vec<Term> = handle
        .obs
        .epochs
        .iter()
        .map(|e| encode_epoch(env, e))
        .collect();
    list.encode(env)
}

/// Single-frequency pseudoranges for one epoch (by index), with an optional
/// per-system code override map `[{"G", ["C1C"]}, ...]` (an empty list uses the
/// crate's version-aware defaults).
///
/// Returns `{:ok, [{"G01", range_m}, ...]}` (exactly the solver's input shape)
/// or `{:error, :epoch_out_of_range}`.
#[rustler::nif(schedule = "DirtyCpu")]
fn rinex_obs_pseudoranges(
    env: Env<'_>,
    handle: ResourceArc<RinexObsResource>,
    epoch_index: usize,
    overrides: Vec<(String, Vec<String>)>,
) -> Term<'_> {
    let Some(epoch) = handle.obs.epochs.get(epoch_index) else {
        let reason = rustler::types::atom::Atom::from_str(env, "epoch_out_of_range")
            .map(|a| a.encode(env))
            .unwrap_or_else(|_| "epoch_out_of_range".encode(env));
        return (rustler::types::atom::error(), reason).encode(env);
    };

    // An empty override list uses the crate's version-aware defaults across all
    // systems; a non-empty override list defines the policy on its own (only the
    // listed systems are extracted), so a GPS-only request never pulls in, say,
    // GLONASS satellites that a later correction cannot model.
    let policy = if overrides.is_empty() {
        match SignalPolicy::default_for(handle.obs.header.version) {
            Ok(policy) => policy,
            Err(_) => {
                return (rustler::types::atom::error(), atoms::invalid_input()).encode(env);
            }
        }
    } else {
        let mut codes = std::collections::BTreeMap::new();
        for (letter, code_list) in overrides {
            if let Some(c) = letter.chars().next() {
                if let Some(system) = GnssSystem::from_letter(c) {
                    codes.insert(system, code_list);
                }
            }
        }
        SignalPolicy { codes }
    };

    let prs: Vec<(String, f64)> = match pseudoranges(&handle.obs, epoch, &policy) {
        Ok(rows) => rows
            .into_iter()
            .map(|(sat, range_m)| (sat.to_string(), range_m))
            .collect(),
        Err(_) => {
            return (rustler::types::atom::error(), atoms::invalid_input()).encode(env);
        }
    };

    (rustler::types::atom::ok(), prs).encode(env)
}

/// Raw per-satellite observation values for one epoch (by index): for each
/// satellite, every observation code its system carries (in the header's declared
/// order) paired with its value, loss-of-lock indicator (LLI), and signal-strength
/// indicator (SSI). Per the RINEX convention the value is metres for `C*`
/// pseudoranges and cycles for `L*` carrier phase (Hz for `D*` Doppler, etc.);
/// units are the caller's to interpret from the code's leading letter.
///
/// Returns `{:ok, [{"G01", [{"C1C", value | nil, lli | nil, ssi | nil}, ...]}, ...]}`
/// or `{:error, :epoch_out_of_range}`. A blank observation has a `nil` value;
/// trailing blank observations a satellite did not report are simply absent.
/// `overrides` is an optional per-system code filter `[{"G", ["L1C", "L2W"]}, ...]`:
/// an empty list crosses every code for every satellite, while a non-empty list
/// restricts the result to the listed systems only — and, within a listed system,
/// to the listed codes (an empty code list keeps all of that system's codes). This
/// keeps a daily product from marshalling every observable when the caller only
/// wants a few.
#[rustler::nif(schedule = "DirtyCpu")]
fn rinex_obs_values(
    env: Env<'_>,
    handle: ResourceArc<RinexObsResource>,
    epoch_index: usize,
    overrides: Vec<(String, Vec<String>)>,
) -> Term<'_> {
    let Some(epoch) = handle.obs.epochs.get(epoch_index) else {
        let reason = rustler::types::atom::Atom::from_str(env, "epoch_out_of_range")
            .map(|a| a.encode(env))
            .unwrap_or_else(|_| "epoch_out_of_range".encode(env));
        return (rustler::types::atom::error(), reason).encode(env);
    };

    let filter = decode_observation_filter(overrides);
    let values = match observation_values(&handle.obs, epoch, &filter) {
        Ok(values) => values,
        Err(_) => {
            return (rustler::types::atom::error(), atoms::invalid_input()).encode(env);
        }
    };
    let rows: Vec<SatObsRow> = values
        .into_iter()
        .map(|(sat, rows)| {
            (
                sat.to_string(),
                rows.into_iter()
                    .map(|row| {
                        (
                            row.code,
                            row.kind.as_str(),
                            row.kind.units_str(),
                            row.value,
                            row.lli,
                            row.ssi,
                        )
                    })
                    .collect(),
            )
        })
        .collect();

    (rustler::types::atom::ok(), rows).encode(env)
}

/// Carrier-phase observations for one epoch, with frequency, wavelength,
/// phase-shift, and meter-valued phase computed by the crate.
#[rustler::nif(schedule = "DirtyCpu")]
fn rinex_obs_phases(
    env: Env<'_>,
    handle: ResourceArc<RinexObsResource>,
    epoch_index: usize,
    overrides: Vec<(String, Vec<String>)>,
) -> Term<'_> {
    let Some(epoch) = handle.obs.epochs.get(epoch_index) else {
        let reason = rustler::types::atom::Atom::from_str(env, "epoch_out_of_range")
            .map(|a| a.encode(env))
            .unwrap_or_else(|_| "epoch_out_of_range".encode(env));
        return (rustler::types::atom::error(), reason).encode(env);
    };

    let filter = decode_observation_filter(overrides);
    let phase_rows = match carrier_phase_rows(&handle.obs, epoch, &filter) {
        Ok(phase_rows) => phase_rows,
        Err(_) => {
            return (rustler::types::atom::error(), atoms::invalid_input()).encode(env);
        }
    };
    let rows: Vec<SatPhaseRow> = phase_rows
        .into_iter()
        .map(|(sat, rows)| {
            (
                sat.to_string(),
                rows.into_iter()
                    .map(|row| {
                        // The hardened core keeps the SYS / PHASE SHIFT
                        // correction as metadata and leaves value_cycles as the
                        // raw recorded phase. The Elixir layer expects the shift
                        // folded into value_cycles (and value_m) so phases are
                        // aligned to a common reference, so re-apply it here.
                        let value_cycles = row
                            .value_cycles
                            .map(|cycles| cycles + row.phase_shift_cycles);
                        let value_m = value_cycles
                            .zip(row.wavelength_m)
                            .map(|(cycles, lambda)| cycles * lambda);
                        (
                            row.code,
                            value_cycles,
                            row.lli,
                            row.ssi,
                            (
                                row.frequency_hz,
                                row.wavelength_m,
                                value_m,
                                row.phase_shift_cycles,
                            ),
                        )
                    })
                    .collect(),
            )
        })
        .collect();

    (rustler::types::atom::ok(), rows).encode(env)
}

/// Carrier frequency in hertz for a system letter and RINEX band digit.
#[rustler::nif]
fn rinex_obs_band_frequency_hz<'a>(
    env: Env<'a>,
    system: String,
    band: String,
    channel: Term<'a>,
) -> Term<'a> {
    let mut system_chars = system.chars();
    let system = match (system_chars.next(), system_chars.next()) {
        (Some(letter), None) => GnssSystem::from_letter(letter),
        _ => None,
    };
    let mut band_chars = band.chars();
    let band = match (band_chars.next(), band_chars.next()) {
        (Some(letter), None) => Some(letter),
        _ => None,
    };
    let channel = decode_optional_i8(channel).ok().flatten();
    match system
        .zip(band)
        .and_then(|(system, band)| rinex_band_frequency_hz(system, band, channel))
    {
        Some(freq) => freq.encode(env),
        None => rustler::types::atom::nil().encode(env),
    }
}

/// Encode one epoch as `{ {{y,mo,d},{h,mi,second_float}}, flag, sat_count }`.
fn encode_epoch<'a>(env: Env<'a>, epoch: &ObsEpoch) -> Term<'a> {
    let t = &epoch.epoch;
    let datetime = (
        (t.year, t.month as i32, t.day as i32),
        (t.hour as i32, t.minute as i32, t.second),
    );
    (datetime, epoch.flag, epoch.sats.len()).encode(env)
}

fn decode_observation_filter(overrides: Vec<(String, Vec<String>)>) -> ObservationFilter {
    ObservationFilter::from_entries(overrides.into_iter().filter_map(|(letter, codes)| {
        letter
            .chars()
            .next()
            .and_then(GnssSystem::from_letter)
            .map(|system| (system, codes))
    }))
}

fn decode_optional_i8(term: Term<'_>) -> NifResult<Option<i8>> {
    if term.is_atom() && term.atom_to_string().unwrap_or_default() == "nil" {
        return Ok(None);
    }
    let value = term.decode::<i64>()?;
    Ok(i8::try_from(value).ok())
}