//! Rustler boundary for GNSS observable prediction.
//!
//! Pure glue over `sidereon_core::observables`: decode the already-loaded
//! SP3/broadcast resource handle, satellite token pieces, receive epoch, and
//! receiver ECEF; call the crate's predictor; encode the result for Elixir.
use crate::broadcast::BroadcastResource;
use crate::sp3::Sp3Resource;
use rustler::{Encoder, Env, ResourceArc, Term};
use sidereon_core::observables::{
j2000_seconds_from_split, predict, predict_batch, ObservablesError, PredictOptions,
PredictedObservables, PredictRequest,
};
use sidereon_core::{GnssSatelliteId, GnssSystem};
type Vec3 = (f64, f64, f64);
/// One batch request from Elixir: `{system_letter, prn, jd_whole, jd_fraction,
/// receiver_ecef_m}`. The receive epoch is split Julian-date, matching the
/// single-shot `sp3_observables` boundary.
type BatchRequestTerm = (String, u8, f64, f64, Vec3);
mod atoms {
rustler::atoms! {
ok,
error,
no_ephemeris,
invalid_input,
prediction_missing
}
}
#[rustler::nif]
#[allow(clippy::too_many_arguments)]
pub fn sp3_observables<'a>(
env: Env<'a>,
handle: ResourceArc<Sp3Resource>,
system_letter: String,
prn: u8,
jd_whole: f64,
jd_fraction: f64,
receiver_ecef_m: Vec3,
carrier_hz: f64,
light_time: bool,
sagnac: bool,
) -> Term<'a> {
let result = sat_from_parts(&system_letter, prn).and_then(|sat| {
let t_rx_j2000_s =
j2000_seconds_from_split(jd_whole, jd_fraction).map_err(PredictFailure::from)?;
predict(
&handle.sp3,
sat,
vec3_to_array(receiver_ecef_m),
t_rx_j2000_s,
PredictOptions {
carrier_hz,
light_time,
sagnac,
},
)
.map_err(PredictFailure::from)
});
encode_result(env, result)
}
#[rustler::nif]
#[allow(clippy::too_many_arguments)]
pub fn broadcast_observables<'a>(
env: Env<'a>,
handle: ResourceArc<BroadcastResource>,
system_letter: String,
prn: u8,
t_rx_j2000_s: f64,
receiver_ecef_m: Vec3,
carrier_hz: f64,
light_time: bool,
sagnac: bool,
) -> Term<'a> {
let result = sat_from_parts(&system_letter, prn).and_then(|sat| {
predict(
&handle.store,
sat,
vec3_to_array(receiver_ecef_m),
t_rx_j2000_s,
PredictOptions {
carrier_hz,
light_time,
sagnac,
},
)
.map_err(PredictFailure::from)
});
encode_result(env, result)
}
/// Predict observables for many `{satellite, epoch, receiver}` requests against
/// one loaded SP3 product in a single boundary crossing. Element `i` of the
/// returned list is the per-request `{:ok, _}` / `{:error, _}` for `requests[i]`.
#[rustler::nif(schedule = "DirtyCpu")]
pub fn sp3_predict_batch<'a>(
env: Env<'a>,
handle: ResourceArc<Sp3Resource>,
requests: Vec<BatchRequestTerm>,
carrier_hz: f64,
light_time: bool,
sagnac: bool,
) -> Term<'a> {
let options = PredictOptions {
carrier_hz,
light_time,
sagnac,
};
// Resolve every request's satellite/epoch up front so a malformed request is
// reported in place (preserving index alignment) without entering the core.
let mut prepared: Vec<Result<PredictRequest, PredictFailure>> =
Vec::with_capacity(requests.len());
for (system_letter, prn, jd_whole, jd_fraction, receiver_ecef_m) in requests {
let resolved = sat_from_parts(&system_letter, prn).and_then(|sat| {
let t_rx_j2000_s =
j2000_seconds_from_split(jd_whole, jd_fraction).map_err(PredictFailure::from)?;
Ok((sat, vec3_to_array(receiver_ecef_m), t_rx_j2000_s))
});
prepared.push(resolved);
}
// The valid requests are predicted as a batch in the core; the invalid ones
// are stitched back into their original slots.
let valid: Vec<PredictRequest> = prepared.iter().filter_map(|r| r.clone().ok()).collect();
let mut predicted = predict_batch(&handle.sp3, &valid, options).into_iter();
let rows: Vec<Term> = prepared
.into_iter()
.map(|prep| match prep {
// A valid request consumes the next core prediction. A short result
// stream (fewer predictions than valid requests) is a core-contract
// breach, not a request fault; report this slot as a typed error
// rather than panic across the NIF boundary.
Ok(_) => match predicted.next() {
Some(result) => encode_result(env, result.map_err(PredictFailure::from)),
None => (atoms::error(), atoms::prediction_missing()).encode(env),
},
Err(failure) => encode_result(env, Err(failure)),
})
.collect();
rows.encode(env)
}
#[derive(Debug, Clone)]
enum PredictFailure {
NoEphemeris,
InvalidInput,
Reason(String),
}
impl From<ObservablesError> for PredictFailure {
fn from(value: ObservablesError) -> Self {
match value {
ObservablesError::NoEphemeris => Self::NoEphemeris,
ObservablesError::InvalidInput { .. } => Self::InvalidInput,
ObservablesError::Ephemeris(err) => Self::Reason(err.to_string()),
}
}
}
fn sat_from_parts(system_letter: &str, prn: u8) -> Result<GnssSatelliteId, PredictFailure> {
let Some(letter) = system_letter.chars().next() else {
return Err(PredictFailure::Reason(
"empty GNSS system letter".to_string(),
));
};
let Some(system) = GnssSystem::from_letter(letter) else {
return Err(PredictFailure::Reason(format!(
"unknown GNSS system letter {system_letter:?}"
)));
};
GnssSatelliteId::new(system, prn).map_err(|_| PredictFailure::InvalidInput)
}
fn vec3_to_array(vec: Vec3) -> [f64; 3] {
[vec.0, vec.1, vec.2]
}
fn array_to_vec3(array: [f64; 3]) -> Vec3 {
(array[0], array[1], array[2])
}
fn encode_result<'a>(
env: Env<'a>,
result: Result<PredictedObservables, PredictFailure>,
) -> Term<'a> {
match result {
Ok(obs) => {
let clock = match obs.sat_clock_s {
Some(clock_s) => clock_s.encode(env),
None => rustler::types::atom::nil().encode(env),
};
let scalars = vec![
obs.geometric_range_m.encode(env),
obs.range_rate_m_s.encode(env),
obs.doppler_hz.encode(env),
clock,
obs.elevation_deg.encode(env),
obs.azimuth_deg.encode(env),
obs.transmit_offset_us.encode(env),
obs.transmit_time_j2000_s.encode(env),
];
let vectors = vec![
array_to_vec3(obs.los_unit).encode(env),
array_to_vec3(obs.sat_pos_ecef_m).encode(env),
array_to_vec3(obs.sat_velocity_m_s).encode(env),
];
(atoms::ok(), (scalars, vectors)).encode(env)
}
Err(PredictFailure::NoEphemeris) => (atoms::error(), atoms::no_ephemeris()).encode(env),
Err(PredictFailure::InvalidInput) => (atoms::error(), atoms::invalid_input()).encode(env),
Err(PredictFailure::Reason(reason)) => (atoms::error(), reason).encode(env),
}
}