Skip to main content

tempoch_core/model/
civil.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! Civil layer: `chrono::DateTime<Utc>` interop plus Unix and GPS
5//! representations.
6
7use crate::data::runtime_data::{
8    time_data_tai_seconds_from_utc, time_data_tai_seconds_is_in_leap_window,
9    time_data_try_tai_minus_utc_mjd, time_data_utc_from_tai_seconds,
10};
11use crate::earth::context::TimeContext;
12use crate::encoding::{day_to_j2000_seconds, unix_seconds_to_mjd};
13use crate::format::TimeFormat;
14use crate::format::MJD;
15use crate::foundation::constats::gps_epoch_tai_seconds;
16use crate::foundation::error::ConversionError;
17use crate::model::scale::{TAI, UTC};
18use crate::model::time::Time;
19use chrono::{DateTime, Utc};
20use qtty::Second;
21
22impl<F: TimeFormat> Time<UTC, F> {
23    /// Build a UTC instant from a `chrono::DateTime<Utc>` using the context's
24    /// captured time-data bundle.
25    #[inline]
26    pub fn try_from_chrono_with(
27        dt: DateTime<Utc>,
28        ctx: &TimeContext,
29    ) -> Result<Time<UTC, crate::format::J2000s>, ConversionError> {
30        let tai_secs =
31            time_data_tai_seconds_from_utc(ctx.time_data(), dt, ctx.allows_pre_definition_utc())?;
32        Time::<UTC, crate::format::J2000s>::try_from_raw_j2000_seconds_split(
33            tai_secs,
34            Second::new(0.0),
35        )
36    }
37
38    /// Build a UTC instant from a `chrono::DateTime<Utc>`.
39    ///
40    /// Snapshots the active time-data bundle at call time via
41    /// [`TimeContext::new`]. For reproducible pipelines, prefer
42    /// [`try_from_chrono_with`](Self::try_from_chrono_with) with an explicit
43    /// context.
44    #[inline]
45    pub fn try_from_chrono(
46        dt: DateTime<Utc>,
47    ) -> Result<Time<UTC, crate::format::J2000s>, ConversionError> {
48        Self::try_from_chrono_with(dt, &TimeContext::new())
49    }
50
51    /// Convenience panicking wrapper over
52    /// [`try_from_chrono_with`](Self::try_from_chrono_with).
53    #[track_caller]
54    #[inline]
55    pub fn from_chrono_with(
56        dt: DateTime<Utc>,
57        ctx: &TimeContext,
58    ) -> Time<UTC, crate::format::J2000s> {
59        Self::try_from_chrono_with(dt, ctx)
60            .expect("UTC conversion failed; use try_from_chrono_with")
61    }
62
63    /// Convenience panicking wrapper over [`try_from_chrono`](Self::try_from_chrono).
64    ///
65    /// Snapshots the active time-data bundle at call time via
66    /// [`TimeContext::new`]. For reproducible pipelines, prefer
67    /// [`from_chrono_with`](Self::from_chrono_with).
68    #[track_caller]
69    #[inline]
70    pub fn from_chrono(dt: DateTime<Utc>) -> Time<UTC, crate::format::J2000s> {
71        Self::try_from_chrono(dt).expect("UTC conversion failed; use try_from_chrono")
72    }
73
74    /// Convert to a `chrono::DateTime<Utc>`, preserving leap-second labels,
75    /// using the context's captured time-data bundle.
76    #[inline]
77    pub fn try_to_chrono_with(self, ctx: &TimeContext) -> Result<DateTime<Utc>, ConversionError> {
78        time_data_utc_from_tai_seconds(
79            ctx.time_data(),
80            self.to_j2000s().total_seconds(),
81            ctx.allows_pre_definition_utc(),
82        )
83    }
84
85    /// Convert to a `chrono::DateTime<Utc>`, preserving leap-second labels.
86    ///
87    /// Snapshots the active time-data bundle at call time via
88    /// [`TimeContext::new`]. For reproducible pipelines, prefer
89    /// [`try_to_chrono_with`](Self::try_to_chrono_with) with an explicit
90    /// context.
91    #[inline]
92    pub fn try_to_chrono(self) -> Result<DateTime<Utc>, ConversionError> {
93        self.try_to_chrono_with(&TimeContext::new())
94    }
95
96    /// Convenience non-fallible wrapper (returns `None` on error) using the
97    /// context's captured time-data bundle.
98    #[inline]
99    pub fn to_chrono_with(self, ctx: &TimeContext) -> Option<DateTime<Utc>> {
100        self.try_to_chrono_with(ctx).ok()
101    }
102
103    /// Convenience non-fallible wrapper (returns `None` on error).
104    ///
105    /// Snapshots the active time-data bundle at call time via
106    /// [`TimeContext::new`]. For reproducible pipelines, prefer
107    /// [`to_chrono_with`](Self::to_chrono_with).
108    #[inline]
109    pub fn to_chrono(self) -> Option<DateTime<Utc>> {
110        self.try_to_chrono().ok()
111    }
112
113    /// Build a UTC instant from a POSIX timestamp in seconds using the
114    /// context's captured time-data bundle.
115    #[inline]
116    pub(crate) fn from_raw_unix_seconds_with(
117        seconds: Second,
118        ctx: &TimeContext,
119    ) -> Result<Time<UTC, crate::format::J2000s>, ConversionError> {
120        if seconds.value().is_nan() {
121            return Err(ConversionError::NonFinite);
122        }
123        let mjd_utc = unix_seconds_to_mjd(seconds);
124        let tai_minus_utc = time_data_try_tai_minus_utc_mjd(
125            ctx.time_data(),
126            mjd_utc,
127            ctx.allows_pre_definition_utc(),
128        )?;
129        let tai_secs = day_to_j2000_seconds::<MJD>(mjd_utc) + tai_minus_utc;
130        Time::<UTC, crate::format::J2000s>::try_from_raw_j2000_seconds_split(
131            tai_secs,
132            Second::new(0.0),
133        )
134    }
135
136    /// Return the POSIX timestamp in seconds for this UTC instant using the
137    /// context's captured time-data bundle.
138    #[inline]
139    pub(crate) fn raw_unix_seconds_with(
140        self,
141        ctx: &TimeContext,
142    ) -> Result<Second, ConversionError> {
143        if self.to_j2000s().is_leap_second_with(ctx) {
144            return Err(ConversionError::InvalidLeapSecond);
145        }
146        let dt = self.try_to_chrono_with(ctx)?;
147        let nanos = dt.timestamp_subsec_nanos();
148        Ok(Second::new(dt.timestamp() as f64 + nanos as f64 / 1e9))
149    }
150
151    /// Returns `true` if this instant falls inside a positive leap second in
152    /// UTC (e.g. 23:59:60) using the context's captured time-data bundle.
153    #[inline]
154    pub fn is_leap_second_with(self, ctx: &TimeContext) -> bool {
155        time_data_tai_seconds_is_in_leap_window(ctx.time_data(), self.to_j2000s().total_seconds())
156    }
157
158    /// Returns `true` if this instant falls inside a positive leap second
159    /// in UTC (e.g. 23:59:60).
160    ///
161    /// Snapshots the active time-data bundle at call time via
162    /// [`TimeContext::new`]. For reproducible pipelines, prefer
163    /// [`is_leap_second_with`](Self::is_leap_second_with).
164    #[inline]
165    pub fn is_leap_second(self) -> bool {
166        self.is_leap_second_with(&TimeContext::new())
167    }
168}
169
170impl<F: TimeFormat> Time<TAI, F> {
171    /// Build a TAI instant from GPS seconds since the GPS epoch.
172    #[inline]
173    pub(crate) fn from_raw_gps_seconds(
174        seconds: Second,
175    ) -> Result<Time<TAI, crate::format::J2000s>, ConversionError> {
176        if seconds.value().is_nan() {
177            return Err(ConversionError::NonFinite);
178        }
179        Time::<TAI, crate::format::J2000s>::try_from_raw_j2000_seconds_split(
180            seconds + gps_epoch_tai_seconds(),
181            Second::new(0.0),
182        )
183    }
184
185    /// Return GPS seconds since the GPS epoch for this instant.
186    #[inline]
187    pub(crate) fn raw_gps_seconds(self) -> Second {
188        self.to_j2000s().total_seconds() - gps_epoch_tai_seconds()
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::data::runtime_data::{active_time_data, with_test_time_data};
196
197    #[test]
198    fn chrono_convenience_wrappers_roundtrip_with_context() {
199        let bundle = active_time_data().as_ref().clone();
200        with_test_time_data(bundle, || {
201            let ctx = TimeContext::new();
202            let dt = DateTime::from_timestamp(946_728_000, 125_000_000).unwrap();
203
204            let with_ctx = Time::<UTC>::from_chrono_with(dt, &ctx);
205            let default_ctx = Time::<UTC>::from_chrono(dt);
206            assert_eq!(with_ctx, default_ctx);
207
208            let back_with_ctx = with_ctx.to_chrono_with(&ctx).unwrap();
209            let back_default = with_ctx.to_chrono().unwrap();
210            let with_ctx_delta_ns =
211                back_with_ctx.timestamp_nanos_opt().unwrap() - dt.timestamp_nanos_opt().unwrap();
212            let default_delta_ns =
213                back_default.timestamp_nanos_opt().unwrap() - dt.timestamp_nanos_opt().unwrap();
214
215            assert!(with_ctx_delta_ns.abs() < 50_000);
216            assert!(default_delta_ns.abs() < 50_000);
217        });
218    }
219
220    #[test]
221    fn gps_raw_seconds_reject_nan_and_roundtrip_finite() {
222        assert!(matches!(
223            Time::<TAI>::from_raw_gps_seconds(Second::new(f64::NAN)),
224            Err(ConversionError::NonFinite)
225        ));
226
227        let tai = Time::<TAI>::from_raw_gps_seconds(Second::new(123.5)).unwrap();
228        assert_eq!(tai.raw_gps_seconds(), Second::new(123.5));
229    }
230}