tempoch 0.4.2

Astronomical time primitives: typed time scales, Julian dates, UTC conversion, and interval operations.
Documentation
use chrono::{DateTime, NaiveDate};
use qtty::{Day, Second};
#[cfg(feature = "serde")]
use serde_json::json;
use tempoch::{
    constats::{J2000_JD_TT, TT_MINUS_TAI},
    ConversionError, CoordinateScale, J2000Seconds, J2000s, JulianDate, ModifiedJulianDate, Period,
    Time, TimeContext, Unix, JD, MJD, TAI, TT, UT1, UTC,
};

#[test]
fn utc_roundtrip_j2000_is_stable() {
    let datetime = DateTime::from_timestamp(946_728_000, 0).unwrap();
    let utc = Time::<UTC>::try_from_chrono(datetime).unwrap();
    let jd_tt: Time<TT> = utc.to::<TT>();
    let back = jd_tt.to::<UTC>().try_to_chrono().unwrap();
    let delta_ns = back.timestamp_nanos_opt().unwrap() - datetime.timestamp_nanos_opt().unwrap();
    assert!(delta_ns.abs() < 50_000);
}

#[test]
fn ut1_context_roundtrip_near_j2000() {
    let ctx = TimeContext::new();
    let tt = J2000Seconds::<TT>::try_new(Second::new(0.0))
        .unwrap()
        .to_time();
    let ut1 = tt.to_with::<UT1>(&ctx).unwrap();
    let tt_back = ut1.to_with::<TT>(&ctx).unwrap();

    let offset_s = tt.to::<J2000s>().raw() - ut1.to::<J2000s>().raw();
    assert!((offset_s - Second::new(63.83)).abs() < Second::new(1.0));
    assert!((tt - tt_back).abs() < Second::new(1e-9));
}

#[test]
fn default_try_to_ut1_uses_default_context() {
    let tt = J2000Seconds::<TT>::try_new(Second::new(0.0))
        .unwrap()
        .to_time();
    let via_default: Time<UT1> = tt.try_to::<UT1>().unwrap();
    let via_context: Time<UT1> = tt.to_with::<UT1>(&TimeContext::new()).unwrap();
    assert!(
        (via_default.to::<J2000s>().raw() - via_context.to::<J2000s>().raw()).abs()
            < Second::new(1e-12)
    );
}

#[test]
fn public_constats_epochs_are_usable() {
    let j2000 = JulianDate::<TT>::try_new(J2000_JD_TT).unwrap().to_time();
    let tai_s: Time<TAI> = j2000.to::<TAI>();

    assert_eq!(j2000.to::<JD>().raw(), J2000_JD_TT);
    assert!(
        ((j2000.to::<J2000s>().raw() - tai_s.to::<J2000s>().raw()) - TT_MINUS_TAI).abs()
            < Second::new(1e-12)
    );
}

#[test]
fn utc_leap_second_roundtrip_is_preserved() {
    let leap = NaiveDate::from_ymd_opt(2016, 12, 31)
        .unwrap()
        .and_hms_nano_opt(23, 59, 59, 1_250_000_000)
        .unwrap()
        .and_utc();

    let utc = Time::<UTC>::try_from_chrono(leap).unwrap();
    let back = utc.try_to_chrono().unwrap();

    assert!(utc.is_leap_second());
    assert!(utc.try_to::<Unix>().is_err());
    assert_eq!(back.timestamp(), leap.timestamp());
    assert!(
        (back.timestamp_subsec_nanos() as i64 - leap.timestamp_subsec_nanos() as i64).abs()
            < 50_000
    );
    assert!(format!("{back:?}").starts_with("2016-12-31T23:59:60."));
}

#[test]
fn utc_supports_coordinate_views_and_pre_1961_roundtrips() {
    fn needs_coordinate_scale<S: CoordinateScale>(time: Time<S>) -> Day {
        time.to::<MJD>().raw()
    }

    let utc = J2000Seconds::<UTC>::try_new(Second::new(0.0))
        .unwrap()
        .to_time();
    assert_eq!(needs_coordinate_scale(utc), utc.to::<MJD>().raw());

    let pre_1961 = DateTime::from_timestamp(-631_152_000, 500_000_000).unwrap();

    // Default: pre-1961 dates must be rejected.
    assert!(matches!(
        Time::<UTC>::try_from_chrono(pre_1961),
        Err(ConversionError::UtcBeforeDefinition)
    ));

    // Opt-in: round-trip must close.
    let ctx = TimeContext::new().allow_pre_definition_utc();
    let encoded = Time::<UTC>::try_from_chrono_with(pre_1961, &ctx).unwrap();
    let back = encoded.try_to_chrono_with(&ctx).unwrap();
    let delta_ns = back.timestamp_nanos_opt().unwrap() - pre_1961.timestamp_nanos_opt().unwrap();
    assert!(delta_ns.abs() < 50_000);
}

#[test]
fn interval_set_ops_match_expected_intervals() {
    let outer = Period::<TT>::new(
        ModifiedJulianDate::<TT>::try_new(Day::new(0.0))
            .unwrap()
            .to_time(),
        ModifiedJulianDate::<TT>::try_new(Day::new(10.0))
            .unwrap()
            .to_time(),
    );
    let a = vec![
        Period::<TT>::new(
            ModifiedJulianDate::<TT>::try_new(Day::new(1.0))
                .unwrap()
                .to_time(),
            ModifiedJulianDate::<TT>::try_new(Day::new(3.0))
                .unwrap()
                .to_time(),
        ),
        Period::<TT>::new(
            ModifiedJulianDate::<TT>::try_new(Day::new(5.0))
                .unwrap()
                .to_time(),
            ModifiedJulianDate::<TT>::try_new(Day::new(9.0))
                .unwrap()
                .to_time(),
        ),
    ];
    let b = vec![
        Period::<TT>::new(
            ModifiedJulianDate::<TT>::try_new(Day::new(2.0))
                .unwrap()
                .to_time(),
            ModifiedJulianDate::<TT>::try_new(Day::new(4.0))
                .unwrap()
                .to_time(),
        ),
        Period::<TT>::new(
            ModifiedJulianDate::<TT>::try_new(Day::new(7.0))
                .unwrap()
                .to_time(),
            ModifiedJulianDate::<TT>::try_new(Day::new(8.0))
                .unwrap()
                .to_time(),
        ),
    ];

    let below_b = outer.complement(&b);
    let between = Period::intersect_many(&a, &below_b);

    assert_eq!(between.len(), 3);
    assert_eq!(between[0].start.to::<MJD>().raw(), Day::new(1.0));
    assert_eq!(between[0].end.to::<MJD>().raw(), Day::new(2.0));
    assert_eq!(between[1].start.to::<MJD>().raw(), Day::new(5.0));
    assert_eq!(between[1].end.to::<MJD>().raw(), Day::new(7.0));
    assert_eq!(between[2].start.to::<MJD>().raw(), Day::new(8.0));
    assert_eq!(between[2].end.to::<MJD>().raw(), Day::new(9.0));
}

#[cfg(feature = "serde")]
#[test]
fn public_serde_roundtrips_time_and_periods() {
    let tt = J2000Seconds::<TT>::try_new(Second::new(12.5))
        .unwrap()
        .to_time();
    let jd = JulianDate::<TT>::try_new(Day::new(2_451_545.25))
        .unwrap()
        .to_time();
    let mjd_period = Period::<TT>::new(
        ModifiedJulianDate::<TT>::try_new(Day::new(61_000.0))
            .unwrap()
            .to_time(),
        ModifiedJulianDate::<TT>::try_new(Day::new(61_001.0))
            .unwrap()
            .to_time(),
    );

    assert_eq!(
        serde_json::to_value(tt).unwrap(),
        json!({"hi": 12.5, "lo": 0.0})
    );
    assert_eq!(
        serde_json::to_value(jd).unwrap(),
        json!({"hi": 21600.0, "lo": 0.0})
    );

    assert_eq!(
        serde_json::from_value::<Time<TT>>(json!({"hi": 12.5, "lo": 0.0})).unwrap(),
        tt
    );
    assert_eq!(
        serde_json::from_value::<Time<TT>>(json!({"hi": 21600.0, "lo": 0.0})).unwrap(),
        jd
    );
    assert_eq!(
        serde_json::to_value(mjd_period).unwrap(),
        json!({
            "start": {"hi": 816955200.0, "lo": 0.0},
            "end": {"hi": 817041600.0, "lo": 0.0}
        })
    );
    assert_eq!(
        serde_json::from_value::<Period<TT>>(json!({
            "start": {"hi": 816955200.0, "lo": 0.0},
            "end": {"hi": 817041600.0, "lo": 0.0}
        }))
        .unwrap(),
        mjd_period
    );
}

#[cfg(feature = "serde")]
#[test]
fn serde_still_works_with_current_shape() {
    let tt = J2000Seconds::<TT>::try_new(Second::new(1.25))
        .unwrap()
        .to_time();
    let period = Period::<TT>::new(
        J2000Seconds::<TT>::try_new(Second::new(1.25))
            .unwrap()
            .to_time(),
        J2000Seconds::<TT>::try_new(Second::new(2.5))
            .unwrap()
            .to_time(),
    );

    assert_eq!(
        serde_json::from_value::<Time<TT>>(json!({"hi": 1.25, "lo": 0.0})).unwrap(),
        tt
    );
    assert_eq!(
        serde_json::from_value::<Period<TT>>(json!({
            "start": {"hi": 1.25, "lo": 0.0},
            "end": {"hi": 2.5, "lo": 0.0}
        }))
        .unwrap(),
        period
    );
}

#[cfg(feature = "serde")]
#[test]
fn tagged_serde_preserves_scale_in_payload() {
    use tempoch::tagged::{TaggedPeriod, TaggedTime};

    let tt = J2000Seconds::<TT>::try_new(Second::new(1.25))
        .unwrap()
        .to_time();
    let period = Period::<TT>::new(
        J2000Seconds::<TT>::try_new(Second::new(1.25))
            .unwrap()
            .to_time(),
        J2000Seconds::<TT>::try_new(Second::new(2.5))
            .unwrap()
            .to_time(),
    );

    assert_eq!(
        serde_json::to_value(TaggedTime(tt)).unwrap(),
        json!({"scale": "TT", "hi": 1.25, "lo": 0.0})
    );
    assert_eq!(
        serde_json::to_value(TaggedPeriod(period)).unwrap(),
        json!({
            "scale": "TT",
            "start": {"scale": "TT", "hi": 1.25, "lo": 0.0},
            "end": {"scale": "TT", "hi": 2.5, "lo": 0.0}
        })
    );
}