Skip to main content

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