Skip to main content

native/sidereon_nif/src/staleness.rs

//! Rustler boundary for the `sidereon-core` product-staleness selection layer
//! (`sidereon_core::staleness`).
//!
//! This module is **pure glue**: it gathers the caller's already-parsed IONEX /
//! SP3 resource handles, calls the crate's `select_*` functions under a
//! [`StalenessPolicy`], and encodes the [`StalenessMetadata`] plus a usable
//! handle (or an index into the input set) back to Elixir. No selection,
//! degradation, or interpolation math lives here, and there is no networking:
//! fetching the products is a per-binding concern handled elsewhere.
//!
//! ## Result shape
//!
//! - SP3 selection only ever borrows one of the input products (exact or
//!   nearest-prior, never a synthesized product), so the selected product is
//!   returned as its INDEX into the input list. The Elixir wrapper maps that
//!   index back to the original `%Sidereon.GNSS.SP3{}` struct (with its coverage
//!   metadata intact), so the present/exact path is bit-for-bit the caller's own
//!   product.
//! - IONEX selection is either the present product (returned as an index, so the
//!   exact path is the caller's own untouched grid) or a whole-day diurnal-shift
//!   copy (returned as a fresh resource handle). The two cases are tagged
//!   `{:present, index}` / `{:shifted, handle}`.
//!
//! Every success carries the staleness metadata tuple
//! `{kind, requested_epoch_j2000_s, source_epoch_j2000_s, staleness_s,
//! staleness_days}`; a typed `{:error, reason}` is returned otherwise, so a
//! degraded answer is never substituted silently.

use rustler::{Encoder, Env, ResourceArc, Term};

use sidereon_core::atmosphere::ionosphere::Ionex;
use sidereon_core::ephemeris::Sp3;
use sidereon_core::staleness::{
    select_ionex, select_ionex_over_range, select_sp3, select_sp3_over_range, DegradationKind,
    SelectionError, StalenessMetadata, StalenessPolicy,
};

use crate::iono::IonexResource;
use crate::sp3::Sp3Resource;
use crate::spp::atom_from;

/// Encode a [`StalenessMetadata`] as the Elixir tuple
/// `{kind, requested_epoch_j2000_s, source_epoch_j2000_s, staleness_s,
/// staleness_days}`. Shared with the precise-to-broadcast fallback encoder.
pub(crate) fn metadata_term<'a>(env: Env<'a>, m: &StalenessMetadata) -> Term<'a> {
    let kind = match m.kind {
        DegradationKind::Exact => "exact",
        DegradationKind::NearestPrior => "nearest_prior",
        DegradationKind::DiurnalShift => "diurnal_shift",
    };
    (
        atom_from(env, kind),
        m.requested_epoch_j2000_s,
        m.source_epoch_j2000_s,
        m.staleness_s,
        m.staleness_days,
    )
        .encode(env)
}

/// Encode a [`SelectionError`] as a typed Elixir reason term. Shared with the
/// fallback encoder, where a declined precise selection surfaces as
/// `{:precise_unavailable, reason}`.
pub(crate) fn selection_error_term<'a>(env: Env<'a>, error: &SelectionError) -> Term<'a> {
    match error {
        SelectionError::EmptyProductSet => atom_from(env, "empty_product_set"),
        SelectionError::InvalidRange {
            start_epoch_j2000_s,
            end_epoch_j2000_s,
        } => (
            atom_from(env, "invalid_range"),
            *start_epoch_j2000_s,
            *end_epoch_j2000_s,
        )
            .encode(env),
        SelectionError::NoPriorProduct {
            requested_epoch_j2000_s,
        } => (atom_from(env, "no_prior_product"), *requested_epoch_j2000_s).encode(env),
        SelectionError::BeyondStalenessCap {
            requested_epoch_j2000_s,
            source_epoch_j2000_s,
            staleness_s,
            max_staleness_s,
        } => (
            atom_from(env, "beyond_staleness_cap"),
            *requested_epoch_j2000_s,
            *source_epoch_j2000_s,
            *staleness_s,
            *max_staleness_s,
        )
            .encode(env),
        SelectionError::InvalidProduct(message) => {
            (atom_from(env, "invalid_product"), message.clone()).encode(env)
        }
        SelectionError::InvalidPolicy { max_staleness_s } => {
            (atom_from(env, "invalid_policy"), *max_staleness_s).encode(env)
        }
        SelectionError::Overflow { context } => (atom_from(env, "overflow"), *context).encode(env),
    }
}

/// `{:error, reason}` for a failed selection.
fn selection_error_result<'a>(env: Env<'a>, error: &SelectionError) -> Term<'a> {
    (
        rustler::types::atom::error(),
        selection_error_term(env, error),
    )
        .encode(env)
}

/// Locate the borrowed product within the local slice, by identity. The crate's
/// `select_sp3` / `select_ionex` exact and nearest-prior paths return a borrow of
/// one of `products`, so a pointer-identity match recovers its index (the same
/// position as the caller's handle list).
fn index_of_sp3(products: &[Sp3], selected: &Sp3) -> Option<usize> {
    products.iter().position(|p| std::ptr::eq(p, selected))
}

fn index_of_ionex(products: &[Ionex], selected: &Ionex) -> Option<usize> {
    products.iter().position(|p| std::ptr::eq(p, selected))
}

/// Select an SP3 product usable at `requested_epoch_j2000_s`, degrading to the
/// most-recent prior product within the staleness cap.
///
/// Returns `{:ok, {index, metadata}}` where `index` is the position of the
/// selected product in `handles`, or `{:error, reason}`.
#[rustler::nif]
fn staleness_select_sp3<'a>(
    env: Env<'a>,
    handles: Vec<ResourceArc<Sp3Resource>>,
    requested_epoch_j2000_s: f64,
    max_staleness_s: f64,
) -> Term<'a> {
    sp3_selection(
        env,
        handles,
        requested_epoch_j2000_s,
        requested_epoch_j2000_s,
        max_staleness_s,
    )
}

/// Select an SP3 product usable across `[start, end]` (J2000 seconds).
#[rustler::nif]
fn staleness_select_sp3_over_range<'a>(
    env: Env<'a>,
    handles: Vec<ResourceArc<Sp3Resource>>,
    start_epoch_j2000_s: f64,
    end_epoch_j2000_s: f64,
    max_staleness_s: f64,
) -> Term<'a> {
    sp3_selection(
        env,
        handles,
        start_epoch_j2000_s,
        end_epoch_j2000_s,
        max_staleness_s,
    )
}

fn sp3_selection<'a>(
    env: Env<'a>,
    handles: Vec<ResourceArc<Sp3Resource>>,
    start_epoch_j2000_s: f64,
    end_epoch_j2000_s: f64,
    max_staleness_s: f64,
) -> Term<'a> {
    let products: Vec<Sp3> = handles.iter().map(|h| h.sp3.clone()).collect();
    let policy = StalenessPolicy::seconds(max_staleness_s);
    let result = if start_epoch_j2000_s == end_epoch_j2000_s {
        select_sp3(&products, start_epoch_j2000_s, policy)
    } else {
        select_sp3_over_range(&products, start_epoch_j2000_s, end_epoch_j2000_s, policy)
    };
    match result {
        Ok(selection) => {
            let index = index_of_sp3(&products, selection.sp3())
                .expect("selected SP3 product is one of the inputs");
            (
                rustler::types::atom::ok(),
                (index, metadata_term(env, &selection.metadata())),
            )
                .encode(env)
        }
        Err(error) => selection_error_result(env, &error),
    }
}

/// Select an IONEX product usable at `requested_epoch_j2000_s` (J2000 seconds,
/// integer for the IONEX map-epoch axis), degrading to a diurnal-shifted prior
/// product within the staleness cap.
///
/// Returns `{:ok, {selection, metadata}}` where `selection` is
/// `{:present, index}` (the caller's untouched grid) or `{:shifted, handle}`
/// (a fresh whole-day diurnal-shift copy), or `{:error, reason}`.
#[rustler::nif]
fn staleness_select_ionex<'a>(
    env: Env<'a>,
    handles: Vec<ResourceArc<IonexResource>>,
    requested_epoch_j2000_s: i64,
    max_staleness_s: f64,
) -> Term<'a> {
    ionex_selection(
        env,
        handles,
        requested_epoch_j2000_s,
        requested_epoch_j2000_s,
        max_staleness_s,
    )
}

/// Select an IONEX product usable across `[start, end]` (J2000 seconds, integer).
#[rustler::nif]
fn staleness_select_ionex_over_range<'a>(
    env: Env<'a>,
    handles: Vec<ResourceArc<IonexResource>>,
    start_epoch_j2000_s: i64,
    end_epoch_j2000_s: i64,
    max_staleness_s: f64,
) -> Term<'a> {
    ionex_selection(
        env,
        handles,
        start_epoch_j2000_s,
        end_epoch_j2000_s,
        max_staleness_s,
    )
}

fn ionex_selection<'a>(
    env: Env<'a>,
    handles: Vec<ResourceArc<IonexResource>>,
    start_epoch_j2000_s: i64,
    end_epoch_j2000_s: i64,
    max_staleness_s: f64,
) -> Term<'a> {
    let products: Vec<Ionex> = handles.iter().map(|h| h.ionex.clone()).collect();
    let policy = StalenessPolicy::seconds(max_staleness_s);
    let result = if start_epoch_j2000_s == end_epoch_j2000_s {
        select_ionex(&products, start_epoch_j2000_s, policy)
    } else {
        select_ionex_over_range(&products, start_epoch_j2000_s, end_epoch_j2000_s, policy)
    };
    match result {
        Ok(selection) => {
            let metadata = selection.metadata();
            // An exact selection borrows one of the inputs, so it is returned as
            // an index (zero-copy, the caller's own grid). A diurnal shift is a
            // synthesized product, so it is returned as a fresh handle.
            let selection_term = match index_of_ionex(&products, selection.ionex()) {
                Some(index) => (atom_from(env, "present"), index).encode(env),
                None => {
                    let handle = ResourceArc::new(IonexResource {
                        ionex: selection.ionex().clone(),
                    });
                    (atom_from(env, "shifted"), handle).encode(env)
                }
            };
            (
                rustler::types::atom::ok(),
                (selection_term, metadata_term(env, &metadata)),
            )
                .encode(env)
        }
        Err(error) => selection_error_result(env, &error),
    }
}