astrodynamics 0.11.0

Numerical astrodynamics engine for orbit propagation, force models, and flight-dynamics primitives
Documentation
//! Time scales and the public time model.
//!
//! The precise time-scale machinery, which used to be `pub(crate)` inside
//! `orbis_nif` and is now public in the core crate. It exposes three layers:
//!
//! - [`scales`] - the parity-critical UTC->TAI->TT->TDB->UT1 conversion, moved
//!   verbatim from `orbis_nif/src/time_scales.rs`. The numerics are byte-for-byte
//!   identical so the existing Skyfield 0-ULP parity holds.
//! - [`model`] - the public time model type family ([`TimeScale`], [`Instant`],
//!   [`Duration`], [`JulianDateSplit`], [`GnssWeekTow`]).
//! - [`eop`] - time/EOP validity + provenance scaffolding with strict-vs-
//!   permissive policy hooks.
//!
//! The legacy thin [`Time`] (seconds since J2000, used by the propagator) is
//! retained unchanged for backward compatibility.

pub mod eop;
pub mod model;
pub mod scales;

pub use eop::{
    CoverageError, DegradeReason, LeapSecondTable, Ut1Provenance, Validated, ValidityMode,
};
pub use model::{
    Duration, GnssWeekTow, Instant, InstantRepr, JulianDateSplit, TimeScale, SECONDS_PER_WEEK,
};
pub use scales::TimeScales;

/// Legacy lightweight epoch: seconds since the J2000 TDB epoch.
///
/// Kept for the propagator/force-model API surface; the richer public time
/// model lives in [`model`].
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Time {
    pub seconds_since_j2000: f64,
}

impl Time {
    pub fn new(seconds_since_j2000: f64) -> Self {
        Self {
            seconds_since_j2000,
        }
    }

    pub fn tdb(&self) -> f64 {
        self.seconds_since_j2000
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn week_tow_normalizes_overflow() {
        let wt = GnssWeekTow::new(TimeScale::Gpst, 100, SECONDS_PER_WEEK + 5.0).normalized();
        assert_eq!(wt.week, 101);
        assert!((wt.tow_s - 5.0).abs() < 1e-9);
    }

    #[test]
    fn week_tow_borrows_negative() {
        let wt = GnssWeekTow::new(TimeScale::Gpst, 100, -10.0).normalized();
        assert_eq!(wt.week, 99);
        assert!((wt.tow_s - (SECONDS_PER_WEEK - 10.0)).abs() < 1e-6);
    }

    #[test]
    fn week_rollover_unrolls() {
        let wt = GnssWeekTow::new(TimeScale::Gpst, 10, 0.0);
        assert_eq!(wt.unrolled_week(2), 10 + 2048);
    }

    #[test]
    fn ut1_coverage_strict_vs_permissive() {
        let prov = scales::ut1_coverage();
        // Inside coverage: ok, not degraded.
        let mid = (prov.first_jd_tt + prov.last_jd_tt) / 2.0;
        assert_eq!(
            eop::check_ut1_coverage(&prov, mid, ValidityMode::Strict),
            Ok(None)
        );
        // Before coverage: strict errors, permissive degrades.
        let before = prov.first_jd_tt - 1.0;
        assert!(eop::check_ut1_coverage(&prov, before, ValidityMode::Strict).is_err());
        assert_eq!(
            eop::check_ut1_coverage(&prov, before, ValidityMode::Permissive),
            Ok(Some(DegradeReason::BeforeCoverage))
        );
    }

    #[test]
    fn time_scales_from_utc_unchanged_shape() {
        // J2000 epoch sanity: 2000-01-01 12:00:00 UTC.
        let ts = TimeScales::from_utc(2000, 1, 1, 12, 0, 0.0);
        assert!((ts.jd_tt - 2451545.0).abs() < 1e-3);
    }

    #[test]
    fn from_utc_validated_in_coverage_is_bit_identical_and_not_degraded() {
        // J2000 is comfortably inside the embedded UT1/EOP coverage interval.
        let plain = TimeScales::from_utc(2000, 1, 1, 12, 0, 0.0);
        for mode in [ValidityMode::Strict, ValidityMode::Permissive] {
            let v = TimeScales::from_utc_validated(2000, 1, 1, 12, 0, 0.0, mode)
                .expect("in-coverage instant must not error in either mode");
            assert_eq!(v.degraded, None, "in-coverage must not be degraded");
            // The numerics must be the EXACT same bits as the parity path.
            assert_eq!(
                v.value, plain,
                "validated numerics must equal from_utc bit-for-bit"
            );
        }
    }

    #[test]
    fn from_utc_validated_strict_errors_before_coverage() {
        let prov = scales::ut1_coverage();
        // The UT1 table starts at MJD 41684 (1973); pick an instant safely before.
        let (y, m, d) = (1960, 1, 1);
        let plain = TimeScales::from_utc(y, m, d, 0, 0, 0.0);
        assert!(
            plain.jd_tt < prov.first_jd_tt,
            "fixture must be before coverage"
        );

        let err = TimeScales::from_utc_validated(y, m, d, 0, 0, 0.0, ValidityMode::Strict)
            .expect_err("strict mode must error before coverage");
        assert_eq!(
            err,
            CoverageError::OutsideCoverage(DegradeReason::BeforeCoverage)
        );
    }

    #[test]
    fn from_utc_validated_strict_errors_after_coverage() {
        let prov = scales::ut1_coverage();
        // The UT1 table ends at MJD 61239 (~2026); pick an instant safely after.
        let (y, m, d) = (2100, 1, 1);
        let plain = TimeScales::from_utc(y, m, d, 0, 0, 0.0);
        assert!(
            plain.jd_tt > prov.last_jd_tt,
            "fixture must be after coverage"
        );

        let err = TimeScales::from_utc_validated(y, m, d, 0, 0, 0.0, ValidityMode::Strict)
            .expect_err("strict mode must error after coverage");
        assert_eq!(
            err,
            CoverageError::OutsideCoverage(DegradeReason::AfterCoverage)
        );
    }

    #[test]
    fn from_utc_validated_permissive_clamps_and_marks_degraded() {
        // Before coverage: permissive returns the clamped value, marked degraded,
        // and the clamped numerics equal the parity path exactly.
        let plain_before = TimeScales::from_utc(1960, 1, 1, 0, 0, 0.0);
        let before =
            TimeScales::from_utc_validated(1960, 1, 1, 0, 0, 0.0, ValidityMode::Permissive)
                .expect("permissive must not error");
        assert_eq!(before.degraded, Some(DegradeReason::BeforeCoverage));
        assert_eq!(before.value, plain_before);

        // After coverage: permissive returns the clamped value, marked degraded.
        let plain_after = TimeScales::from_utc(2100, 1, 1, 0, 0, 0.0);
        let after = TimeScales::from_utc_validated(2100, 1, 1, 0, 0, 0.0, ValidityMode::Permissive)
            .expect("permissive must not error");
        assert_eq!(after.degraded, Some(DegradeReason::AfterCoverage));
        assert_eq!(after.value, plain_after);
    }
}