astrodynamics 0.12.0

Numerical astrodynamics engine for orbit propagation, force models, and flight-dynamics primitives
Documentation
//! Public time model type family.
//!
//! A bare `Epoch` is ambiguous (element epoch vs observation epoch vs product
//! epoch), so the public surface is a concrete family of types that always name
//! their scale and use a precision-preserving representation:
//!
//! - [`TimeScale`] - the named time scale of an instant.
//! - [`Instant`] - a scale + a split-Julian-date / integer-nanosecond repr.
//! - [`Duration`] - an integer-nanosecond elapsed interval.
//! - [`JulianDateSplit`] - the two-part (whole + fraction) Julian date used to
//!   avoid catastrophic cancellation, matching the Skyfield split convention.
//! - [`GnssWeekTow`] - a GNSS week number + time-of-week with rollover handling.
//!
//! These are representation/value types only. The parity-critical conversion
//! numerics live in [`crate::time::scales`]; this module deliberately holds no
//! transcendental math so it cannot perturb the 0-ULP contract.

pub use crate::constants::time::SECONDS_PER_WEEK;

/// Named time scales supported by the time model.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TimeScale {
    /// Coordinated Universal Time.
    Utc,
    /// International Atomic Time.
    Tai,
    /// Terrestrial Time.
    Tt,
    /// Barycentric Dynamical Time.
    Tdb,
    /// GPS time.
    Gpst,
    /// Galileo System Time.
    Gst,
    /// BeiDou Time.
    Bdt,
}

impl TimeScale {
    /// Short uppercase identifier (`"UTC"`, `"TAI"`, ...).
    pub fn abbrev(self) -> &'static str {
        match self {
            TimeScale::Utc => "UTC",
            TimeScale::Tai => "TAI",
            TimeScale::Tt => "TT",
            TimeScale::Tdb => "TDB",
            TimeScale::Gpst => "GPST",
            TimeScale::Gst => "GST",
            TimeScale::Bdt => "BDT",
        }
    }
}

/// Two-part Julian date (whole day boundary + day fraction).
///
/// Carrying the integer day separately from the fraction preserves
/// sub-microsecond precision across the full Julian-date range, and matches the
/// Skyfield split that [`crate::time::scales::TimeScales`] produces.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct JulianDateSplit {
    /// Integer day boundary (typically `*.0` or `*.5`).
    pub jd_whole: f64,
    /// Residual day fraction relative to `jd_whole`.
    pub fraction: f64,
}

impl JulianDateSplit {
    /// Construct a split Julian date.
    pub fn new(jd_whole: f64, fraction: f64) -> Self {
        Self { jd_whole, fraction }
    }

    /// Recombine into a single `f64` Julian date.
    ///
    /// Note: recombination is itself a float operation and is NOT guaranteed to
    /// be 0-ULP against a reference that consumes the split form directly; keep
    /// the split form when feeding a parity-matched recipe.
    pub fn to_jd(self) -> f64 {
        self.jd_whole + self.fraction
    }
}

/// Internal representation backing an [`Instant`].
///
/// Two reprs are offered to avoid precision loss for different consumers:
/// integer nanoseconds for exact arithmetic (hifitime-style), and the split
/// Julian date for the astronomy/Skyfield path.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum InstantRepr {
    /// Integer nanoseconds since an implied scale epoch (exact arithmetic).
    Nanos(i128),
    /// Two-part Julian date in the instant's own scale.
    JulianDate(JulianDateSplit),
}

/// A point in time, always tagged with its [`TimeScale`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Instant {
    /// The time scale this instant is expressed in.
    pub scale: TimeScale,
    /// The precision-preserving representation.
    pub repr: InstantRepr,
}

impl Instant {
    /// An instant from a split Julian date in the given scale.
    pub fn from_julian_date(scale: TimeScale, jd: JulianDateSplit) -> Self {
        Self {
            scale,
            repr: InstantRepr::JulianDate(jd),
        }
    }

    /// An instant from integer nanoseconds in the given scale.
    pub fn from_nanos(scale: TimeScale, nanos: i128) -> Self {
        Self {
            scale,
            repr: InstantRepr::Nanos(nanos),
        }
    }

    /// The split Julian date, if this instant is stored in that form.
    pub fn julian_date(&self) -> Option<JulianDateSplit> {
        match self.repr {
            InstantRepr::JulianDate(jd) => Some(jd),
            InstantRepr::Nanos(_) => None,
        }
    }
}

/// An elapsed interval, stored as exact integer nanoseconds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct Duration {
    /// Signed elapsed nanoseconds.
    pub nanos: i128,
}

impl Duration {
    /// Zero duration.
    pub const ZERO: Duration = Duration { nanos: 0 };

    /// Construct from integer nanoseconds.
    pub fn from_nanos(nanos: i128) -> Self {
        Self { nanos }
    }

    /// Construct from seconds. Sub-nanosecond input is truncated toward zero.
    pub fn from_seconds(seconds: f64) -> Self {
        Self {
            nanos: (seconds * 1e9) as i128,
        }
    }

    /// Convert to floating-point seconds.
    pub fn as_seconds(self) -> f64 {
        self.nanos as f64 / 1e9
    }
}

/// A GNSS week number + time-of-week, tagged by constellation.
///
/// `week` is the constellation's native (rolled-over) week count; `tow_s` is
/// seconds into that week in `[0, 604800)`. Rollover handling is provided by
/// [`GnssWeekTow::normalized`] and [`GnssWeekTow::unrolled_week`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GnssWeekTow {
    /// Which constellation's week/TOW convention this uses.
    pub system: TimeScale,
    /// Week number (constellation-native, may have rolled over).
    pub week: u32,
    /// Time of week in seconds, nominally `[0, 604800)`.
    pub tow_s: f64,
}

impl GnssWeekTow {
    /// Construct a week/TOW value.
    pub fn new(system: TimeScale, week: u32, tow_s: f64) -> Self {
        Self {
            system,
            week,
            tow_s,
        }
    }

    /// Normalize so `tow_s` lands in `[0, 604800)`, carrying whole weeks into
    /// `week`. Negative `tow_s` borrows from the week count.
    pub fn normalized(self) -> Self {
        let mut week = self.week as i64;
        let mut tow = self.tow_s;
        let weeks_carry = (tow / SECONDS_PER_WEEK).floor();
        week += weeks_carry as i64;
        tow -= weeks_carry * SECONDS_PER_WEEK;
        if week < 0 {
            week = 0;
            tow = 0.0;
        }
        Self {
            system: self.system,
            week: week as u32,
            tow_s: tow,
        }
    }

    /// Apply a 1024-week rollover count to recover the continuous week number
    /// (GPS legacy 10-bit week). `rollovers` is the number of completed
    /// 1024-week eras since the system's epoch.
    pub fn unrolled_week(self, rollovers: u32) -> u32 {
        self.week + rollovers * 1024
    }
}