//! Rustler boundary for GNSS/astronomical time-scale offsets.
//!
//! Pure glue over `sidereon_core::astro::time`: it maps a time-scale
//! abbreviation onto the core [`TimeScale`], calls the relocated offset
//! functions, and encodes `{:ok, seconds}` / `{:error, reason}`. The offset
//! algebra (atomic constants, leap-second resolution) lives in the crate.
use rustler::{Encoder, Env, Term};
use sidereon_core::astro::time::civil;
use sidereon_core::astro::time::model::{Instant, TimeScale};
use sidereon_core::astro::time::scales::{
find_leap_seconds, gps_utc_offset_s as core_gps_utc_offset_s, julian_day_number,
leap_second_table, tai_utc_offset_s as core_tai_utc_offset_s, ut1_coverage,
};
use sidereon_core::astro::time::{timescale_offset_at_s, timescale_offset_s, TimeOffsetError};
mod atoms {
rustler::atoms! {
ok,
error,
unknown_time_scale,
epoch_required,
unsupported,
non_finite_epoch,
invalid_instant
}
}
/// Map a time-scale abbreviation onto the core [`TimeScale`]. Covers every
/// scale the core knows, including the new GLONASST/QZSST GNSS scales.
fn scale_from_abbrev(abbrev: &str) -> Option<TimeScale> {
Some(match abbrev {
"UTC" => TimeScale::Utc,
"TAI" => TimeScale::Tai,
"TT" => TimeScale::Tt,
"TDB" => TimeScale::Tdb,
"GPST" => TimeScale::Gpst,
"GST" => TimeScale::Gst,
"BDT" => TimeScale::Bdt,
"GLONASST" => TimeScale::Glonasst,
"QZSST" => TimeScale::Qzsst,
_ => return None,
})
}
fn encode_offset_error<'a>(env: Env<'a>, err: TimeOffsetError) -> Term<'a> {
let reason = match err {
TimeOffsetError::EpochRequired(scale) => {
(atoms::epoch_required(), scale.to_string()).encode(env)
}
TimeOffsetError::Unsupported(scale) => {
(atoms::unsupported(), scale.to_string()).encode(env)
}
TimeOffsetError::NonFiniteEpoch(scale) => {
(atoms::non_finite_epoch(), scale.to_string()).encode(env)
}
};
(atoms::error(), reason).encode(env)
}
/// Fixed inter-system offset `to - from` in seconds for atomic scales. Errors
/// for the UTC-based scales (UTC/GLONASST), whose offset is epoch-dependent (use
/// [`timescale_offset_at`]), and for TDB.
#[rustler::nif]
fn timescale_offset<'a>(env: Env<'a>, from: String, to: String) -> Term<'a> {
let (Some(from), Some(to)) = (scale_from_abbrev(&from), scale_from_abbrev(&to)) else {
return (atoms::error(), atoms::unknown_time_scale()).encode(env);
};
match timescale_offset_s(from, to) {
Ok(seconds) => (atoms::ok(), seconds).encode(env),
Err(err) => encode_offset_error(env, err),
}
}
/// Leap-aware inter-system offset `to - from` in seconds at `utc_jd` (UTC
/// Julian date). `utc_jd` only matters when a scale is UTC-based.
#[rustler::nif]
fn timescale_offset_at<'a>(env: Env<'a>, from: String, to: String, utc_jd: f64) -> Term<'a> {
let (Some(from), Some(to)) = (scale_from_abbrev(&from), scale_from_abbrev(&to)) else {
return (atoms::error(), atoms::unknown_time_scale()).encode(env);
};
match timescale_offset_at_s(from, to, utc_jd) {
Ok(seconds) => (atoms::ok(), seconds).encode(env),
Err(err) => encode_offset_error(env, err),
}
}
#[rustler::nif]
fn leap_seconds(year: i32, month: i32, day: i32) -> f64 {
leap_seconds_for_date(year, month, day)
}
fn leap_seconds_for_date(year: i32, month: i32, day: i32) -> f64 {
let jd_utc_midnight = julian_day_number(year, month, day) as f64 - 0.5;
find_leap_seconds(jd_utc_midnight)
}
/// GPS - UTC (the GNSS leap-second offset broadcast in the nav message, 18 s
/// from 2017) at UTC midnight on the given date.
#[rustler::nif]
fn gps_utc_offset_s(year: i32, month: i32, day: i32) -> f64 {
core_gps_utc_offset_s(jd_utc_midnight(year, month, day))
}
/// TAI - UTC (the IERS Bulletin C quantity, 37 s from 2017) at UTC midnight on
/// the given date.
#[rustler::nif]
fn tai_utc_offset_s(year: i32, month: i32, day: i32) -> f64 {
core_tai_utc_offset_s(jd_utc_midnight(year, month, day))
}
fn jd_utc_midnight(year: i32, month: i32, day: i32) -> f64 {
julian_day_number(year, month, day) as f64 - 0.5
}
#[rustler::nif]
fn leap_seconds_batch(dates: Vec<(i32, i32, i32)>) -> Vec<f64> {
dates
.into_iter()
.map(|(year, month, day)| leap_seconds_for_date(year, month, day))
.collect()
}
#[rustler::nif]
fn leap_second_table_info() -> (String, i32, i32, u64) {
let table = leap_second_table();
(
table.source.to_string(),
table.first_mjd,
table.last_mjd,
table.entries as u64,
)
}
#[rustler::nif]
fn ut1_coverage_info() -> (String, i32, i32, f64, f64, u64) {
let prov = ut1_coverage();
(
prov.source.to_string(),
prov.first_mjd,
prov.last_mjd,
prov.first_jd_tt,
prov.last_jd_tt,
prov.entries as u64,
)
}
// ── civil-calendar conversions ────────────────────────────────────────────────
//
// Pure glue over `sidereon_core::astro::time::civil`: the binding marshals its
// `NaiveDateTime` / tuple epoch into civil `(year, month, day, hour, minute,
// second)` fields and these forward to the single core conversion. No calendar
// arithmetic lives on the Elixir side anymore.
/// Split Julian date `{jd_whole, fraction}` for a civil instant. Delegates to
/// [`civil::split_julian_date`].
#[rustler::nif]
fn civil_split_julian_date(
year: i32,
month: i32,
day: i32,
hour: i32,
minute: i32,
second: f64,
) -> (f64, f64) {
civil::split_julian_date(year, month, day, hour, minute, second)
}
/// Continuous seconds since the J2000 epoch for a civil instant. Delegates to
/// [`civil::j2000_seconds`].
#[rustler::nif]
fn civil_j2000_seconds(
year: i32,
month: i32,
day: i32,
hour: i32,
minute: i32,
second: f64,
) -> f64 {
civil::j2000_seconds(year, month, day, hour, minute, second)
}
/// Second-of-day in `[0, 86400)` from the clock fields. Delegates to
/// [`civil::second_of_day`].
#[rustler::nif]
fn civil_second_of_day(hour: i32, minute: i32, second: f64) -> f64 {
civil::second_of_day(hour, minute, second)
}
/// Fractional day-of-year for a civil instant. Delegates to
/// [`civil::day_of_year`].
#[rustler::nif]
fn civil_day_of_year(
year: i32,
month: i32,
day: i32,
hour: i32,
minute: i32,
second: f64,
) -> f64 {
civil::day_of_year(year, month, day, hour, minute, second)
}
/// Validated UTC instant from civil fields, returned as the split Julian date
/// `{:ok, {jd_whole, fraction}}`.
///
/// Delegates to [`Instant::from_utc_civil`], the entry the ionosphere/troposphere
/// dispatchers build their `epoch` argument from. Unlike the raw
/// [`civil_split_julian_date`] split, this path runs the core's
/// `JulianDateSplit::new` guard, so an out-of-day clock field is rejected as
/// `{:error, :invalid_instant}` rather than producing an out-of-range fraction.
#[rustler::nif]
fn civil_utc_instant_split<'a>(
env: Env<'a>,
year: i32,
month: i32,
day: i32,
hour: i32,
minute: i32,
second: f64,
) -> Term<'a> {
match Instant::from_utc_civil(year, month, day, hour, minute, second)
.ok()
.and_then(|instant| instant.julian_date())
{
Some(split) => (atoms::ok(), (split.jd_whole, split.fraction)).encode(env),
None => (atoms::error(), atoms::invalid_instant()).encode(env),
}
}