Skip to main content

native/sidereon_nif/src/bodies.rs

//! Rustler boundary for ground-observer Sun/Moon geometry.
//!
//! Pure glue over `sidereon_core::astro::bodies`: it decodes a ground station
//! (geodetic degrees / km altitude) and UTC datetime tuples, calls the crate's
//! observe/rise-set helpers, and encodes the look-angle, illumination, and
//! event results. The ephemeris, topocentric reduction, phase-angle geometry,
//! and event refinement all live in the core; no astronomy lives here.

use rustler::{Encoder, Env, Term};
use sidereon_core::astro::bodies::observe::{moon_az_el, moon_illumination, sun_az_el, BodyAzEl};
use sidereon_core::astro::bodies::rise_set::{
    find_moon_elevation_crossings, find_moon_transits, MoonElevationCrossing,
    MoonElevationCrossingKind, MoonElevationOptions, MoonTransit, MoonTransitKind,
};
use sidereon_core::astro::frames::transforms::GeodeticStationKm;
use sidereon_core::astro::passes::UtcInstant;

type DateTuple = (i32, i32, i32);
type TimeTuple = (i32, i32, i32, i32);
type AzElTerm = (f64, f64, f64);
// (unix_microseconds, kind_atom, elevation_deg)
type EventTerm<'a> = (i64, Term<'a>, f64);

mod atoms {
    rustler::atoms! {
        ok,
        error,
        invalid_input,
        rising,
        setting,
        upper,
        lower
    }
}

fn station(lat_deg: f64, lon_deg: f64, alt_km: f64) -> GeodeticStationKm {
    GeodeticStationKm {
        latitude_deg: lat_deg,
        longitude_deg: lon_deg,
        altitude_km: alt_km,
    }
}

/// Decode an Elixir `{{y,m,d},{h,min,s,us}}` UTC datetime into a [`UtcInstant`].
fn instant(datetime: Term) -> Option<UtcInstant> {
    let ((year, month, day), (hour, minute, second, microsecond)): (DateTuple, TimeTuple) =
        datetime.decode().ok()?;
    UtcInstant::from_utc(year, month, day, hour, minute, second, microsecond)
}

fn az_el_term(body: BodyAzEl) -> AzElTerm {
    (body.azimuth_deg, body.elevation_deg, body.range_km)
}

#[rustler::nif]
fn bodies_sun_az_el<'a>(
    env: Env<'a>,
    lat_deg: f64,
    lon_deg: f64,
    alt_km: f64,
    datetime: Term<'a>,
) -> Term<'a> {
    let Some(time) = instant(datetime) else {
        return (atoms::error(), atoms::invalid_input()).encode(env);
    };
    match sun_az_el(&station(lat_deg, lon_deg, alt_km), time) {
        Ok(body) => (atoms::ok(), az_el_term(body)).encode(env),
        Err(_) => (atoms::error(), atoms::invalid_input()).encode(env),
    }
}

#[rustler::nif]
fn bodies_moon_az_el<'a>(
    env: Env<'a>,
    lat_deg: f64,
    lon_deg: f64,
    alt_km: f64,
    datetime: Term<'a>,
) -> Term<'a> {
    let Some(time) = instant(datetime) else {
        return (atoms::error(), atoms::invalid_input()).encode(env);
    };
    match moon_az_el(&station(lat_deg, lon_deg, alt_km), time) {
        Ok(body) => (atoms::ok(), az_el_term(body)).encode(env),
        Err(_) => (atoms::error(), atoms::invalid_input()).encode(env),
    }
}

#[rustler::nif]
fn bodies_moon_illumination<'a>(
    env: Env<'a>,
    lat_deg: f64,
    lon_deg: f64,
    alt_km: f64,
    datetime: Term<'a>,
) -> Term<'a> {
    let Some(time) = instant(datetime) else {
        return (atoms::error(), atoms::invalid_input()).encode(env);
    };
    match moon_illumination(&station(lat_deg, lon_deg, alt_km), time) {
        Ok(illum) => (
            atoms::ok(),
            (illum.illuminated_fraction, illum.phase_angle_deg),
        )
            .encode(env),
        Err(_) => (atoms::error(), atoms::invalid_input()).encode(env),
    }
}

#[rustler::nif]
fn bodies_moon_elevation_deg<'a>(
    env: Env<'a>,
    lat_deg: f64,
    lon_deg: f64,
    alt_km: f64,
    datetime: Term<'a>,
) -> Term<'a> {
    let Some(time) = instant(datetime) else {
        return (atoms::error(), atoms::invalid_input()).encode(env);
    };
    // Route through `moon_az_el` (not the core `moon_elevation_deg`, which
    // `expect`s and would panic across the NIF on an invalid-but-finite station)
    // so a bad station surfaces as a typed `{:error, :invalid_input}`.
    match moon_az_el(&station(lat_deg, lon_deg, alt_km), time) {
        Ok(body) => (atoms::ok(), body.elevation_deg).encode(env),
        Err(_) => (atoms::error(), atoms::invalid_input()).encode(env),
    }
}

fn crossing_term<'a>(env: Env<'a>, crossing: MoonElevationCrossing) -> EventTerm<'a> {
    let kind = match crossing.kind {
        MoonElevationCrossingKind::Rising => atoms::rising().encode(env),
        MoonElevationCrossingKind::Setting => atoms::setting().encode(env),
    };
    (
        crossing.time.unix_microseconds(),
        kind,
        crossing.elevation_deg,
    )
}

fn transit_term<'a>(env: Env<'a>, transit: MoonTransit) -> EventTerm<'a> {
    let kind = match transit.kind {
        MoonTransitKind::Upper => atoms::upper().encode(env),
        MoonTransitKind::Lower => atoms::lower().encode(env),
    };
    (transit.time.unix_microseconds(), kind, transit.elevation_deg)
}

#[rustler::nif(schedule = "DirtyCpu")]
#[allow(clippy::too_many_arguments)]
fn bodies_find_moon_elevation_crossings<'a>(
    env: Env<'a>,
    lat_deg: f64,
    lon_deg: f64,
    alt_km: f64,
    start_datetime: Term<'a>,
    end_datetime: Term<'a>,
    elevation_threshold_deg: f64,
    step_seconds: f64,
    time_tolerance_seconds: f64,
) -> Term<'a> {
    let (Some(start), Some(end)) = (instant(start_datetime), instant(end_datetime)) else {
        return (atoms::error(), atoms::invalid_input()).encode(env);
    };
    let options = MoonElevationOptions {
        elevation_threshold_deg,
        step_seconds,
        time_tolerance_seconds,
    };
    match find_moon_elevation_crossings(&station(lat_deg, lon_deg, alt_km), start, end, options) {
        Ok(crossings) => {
            let rows: Vec<EventTerm> = crossings
                .into_iter()
                .map(|crossing| crossing_term(env, crossing))
                .collect();
            (atoms::ok(), rows).encode(env)
        }
        Err(_) => (atoms::error(), atoms::invalid_input()).encode(env),
    }
}

#[rustler::nif(schedule = "DirtyCpu")]
#[allow(clippy::too_many_arguments)]
fn bodies_find_moon_transits<'a>(
    env: Env<'a>,
    lat_deg: f64,
    lon_deg: f64,
    alt_km: f64,
    start_datetime: Term<'a>,
    end_datetime: Term<'a>,
    step_seconds: f64,
    time_tolerance_seconds: f64,
) -> Term<'a> {
    let (Some(start), Some(end)) = (instant(start_datetime), instant(end_datetime)) else {
        return (atoms::error(), atoms::invalid_input()).encode(env);
    };
    match find_moon_transits(
        &station(lat_deg, lon_deg, alt_km),
        start,
        end,
        step_seconds,
        time_tolerance_seconds,
    ) {
        Ok(transits) => {
            let rows: Vec<EventTerm> = transits
                .into_iter()
                .map(|transit| transit_term(env, transit))
                .collect();
            (atoms::ok(), rows).encode(env)
        }
        Err(_) => (atoms::error(), atoms::invalid_input()).encode(env),
    }
}