Skip to main content

tempoch_core/
representation.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! Typed encoded representations of [`crate::Time`].
5
6use core::fmt;
7use core::marker::PhantomData;
8
9use crate::context::TimeContext;
10use crate::encoding::{
11    j2000_seconds_to_jd, j2000_seconds_to_mjd, jd_to_j2000_seconds, mjd_to_j2000_seconds,
12};
13use crate::error::ConversionError;
14use crate::scale::conversion::InfallibleScaleConvert;
15use crate::scale::{CoordinateScale, Scale, TAI, UTC};
16use crate::sealed::Sealed;
17use crate::target::{ContextConversionTarget, ConversionTarget, InfallibleConversionTarget};
18use crate::time::Time;
19use qtty::{Day, Quantity, Second, Unit};
20
21/// Marker trait for external time encodings such as JD or Unix time.
22#[allow(private_bounds)]
23pub trait TimeRepresentation: Sealed + Copy + Clone + fmt::Debug + 'static {
24    /// Quantity unit used by this representation.
25    type Unit: Unit;
26
27    /// Human-readable representation name.
28    const NAME: &'static str;
29}
30
31/// Representation witness for scale `S`.
32#[allow(private_bounds)]
33pub trait RepresentationForScale<S: Scale>: TimeRepresentation + Sealed {
34    fn try_from_time(
35        time: Time<S>,
36        ctx: &TimeContext,
37    ) -> Result<Quantity<Self::Unit>, ConversionError>;
38    fn try_into_time(
39        raw: Quantity<Self::Unit>,
40        ctx: &TimeContext,
41    ) -> Result<Time<S>, ConversionError>;
42}
43
44/// Representation witness for scale `S` with context-free round-trips.
45#[allow(private_bounds)]
46pub trait InfallibleRepresentationForScale<S: Scale>: RepresentationForScale<S> + Sealed {
47    fn from_time(time: Time<S>) -> Quantity<Self::Unit>;
48    fn into_time(raw: Quantity<Self::Unit>) -> Time<S>;
49}
50
51/// J2000 seconds on the source scale's coordinate axis.
52#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
53pub struct J2000s;
54
55/// Julian Day on the source scale's coordinate axis.
56#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
57pub struct JD;
58
59/// Modified Julian Day on the source scale's coordinate axis.
60#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
61pub struct MJD;
62
63/// POSIX seconds on the UTC civil axis.
64#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
65pub struct Unix;
66
67/// GPS seconds since the GPS epoch on the TAI/GPS continuous axis.
68#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
69pub struct GPS;
70
71impl Sealed for J2000s {}
72impl Sealed for JD {}
73impl Sealed for MJD {}
74impl Sealed for Unix {}
75impl Sealed for GPS {}
76
77impl TimeRepresentation for J2000s {
78    type Unit = qtty::unit::Second;
79    const NAME: &'static str = "J2000s";
80}
81
82impl TimeRepresentation for JD {
83    type Unit = qtty::unit::Day;
84    const NAME: &'static str = "JD";
85}
86
87impl TimeRepresentation for MJD {
88    type Unit = qtty::unit::Day;
89    const NAME: &'static str = "MJD";
90}
91
92impl TimeRepresentation for Unix {
93    type Unit = qtty::unit::Second;
94    const NAME: &'static str = "Unix";
95}
96
97impl TimeRepresentation for GPS {
98    type Unit = qtty::unit::Second;
99    const NAME: &'static str = "GPS";
100}
101
102/// A typed external representation of a [`Time<S>`] instant.
103pub struct EncodedTime<S: Scale, R: TimeRepresentation> {
104    raw: Quantity<R::Unit>,
105    _marker: PhantomData<fn() -> S>,
106}
107
108impl<S: Scale, R: TimeRepresentation> Copy for EncodedTime<S, R> {}
109
110impl<S: Scale, R: TimeRepresentation> Clone for EncodedTime<S, R> {
111    #[inline]
112    fn clone(&self) -> Self {
113        *self
114    }
115}
116
117impl<S: Scale, R: TimeRepresentation> fmt::Debug for EncodedTime<S, R> {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        f.debug_struct("EncodedTime")
120            .field("scale", &S::NAME)
121            .field("representation", &R::NAME)
122            .field("raw", &self.raw)
123            .finish()
124    }
125}
126
127impl<S: Scale, R: TimeRepresentation> fmt::Display for EncodedTime<S, R>
128where
129    qtty::Quantity<R::Unit>: fmt::Display,
130{
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        fmt::Display::fmt(&self.raw, f)
133    }
134}
135
136impl<S: Scale, R: TimeRepresentation> fmt::LowerExp for EncodedTime<S, R>
137where
138    qtty::Quantity<R::Unit>: fmt::LowerExp,
139{
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        fmt::LowerExp::fmt(&self.raw, f)
142    }
143}
144
145impl<S: Scale, R: TimeRepresentation> fmt::UpperExp for EncodedTime<S, R>
146where
147    qtty::Quantity<R::Unit>: fmt::UpperExp,
148{
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        fmt::UpperExp::fmt(&self.raw, f)
151    }
152}
153
154impl<S: Scale, R: TimeRepresentation> PartialEq for EncodedTime<S, R> {
155    #[inline]
156    fn eq(&self, other: &Self) -> bool {
157        self.raw == other.raw
158    }
159}
160
161impl<S: Scale, R: TimeRepresentation> PartialOrd for EncodedTime<S, R> {
162    #[inline]
163    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
164        self.raw.partial_cmp(&other.raw)
165    }
166}
167
168impl<S: Scale, R: TimeRepresentation> EncodedTime<S, R> {
169    #[inline]
170    pub(crate) const fn new_unchecked(raw: Quantity<R::Unit>) -> Self {
171        Self {
172            raw,
173            _marker: PhantomData,
174        }
175    }
176
177    /// Return the underlying typed quantity.
178    #[inline]
179    pub const fn raw(self) -> Quantity<R::Unit> {
180        self.raw
181    }
182
183    /// Alias for [`Self::raw`].
184    #[inline]
185    pub const fn quantity(self) -> Quantity<R::Unit> {
186        self.raw
187    }
188}
189
190impl<S: Scale, R> EncodedTime<S, R>
191where
192    R: RepresentationForScale<S>,
193{
194    /// Construct a typed encoded instant from its raw quantity.
195    #[inline]
196    pub fn try_new(raw: Quantity<R::Unit>) -> Result<Self, ConversionError> {
197        if raw.is_finite() {
198            Ok(Self::new_unchecked(raw))
199        } else {
200            Err(ConversionError::NonFinite)
201        }
202    }
203
204    /// Convert this encoded instant to the canonical [`Time<S>`] model.
205    ///
206    /// Snapshots the active time-data bundle at call time via
207    /// [`TimeContext::new`]. For reproducible pipelines, prefer
208    /// [`to_time_with`](Self::to_time_with) with an explicit context.
209    #[inline]
210    pub fn try_to_time(self) -> Result<Time<S>, ConversionError> {
211        R::try_into_time(self.raw, &TimeContext::new())
212    }
213
214    /// Convert this encoded instant to the canonical [`Time<S>`] model using an explicit context.
215    #[inline]
216    pub fn to_time_with(self, ctx: &TimeContext) -> Result<Time<S>, ConversionError> {
217        R::try_into_time(self.raw, ctx)
218    }
219}
220
221impl<S: Scale, R> EncodedTime<S, R>
222where
223    R: InfallibleRepresentationForScale<S>,
224{
225    #[inline]
226    pub(crate) fn from_time_infallible(time: Time<S>) -> Self {
227        Self::new_unchecked(R::from_time(time))
228    }
229
230    /// Infallible conversion to the canonical [`Time<S>`] model.
231    #[inline]
232    pub fn to_time(self) -> Time<S> {
233        R::into_time(self.raw)
234    }
235
236    /// Unified infallible conversion to a target scale or encoded format.
237    #[allow(private_bounds)]
238    #[inline]
239    pub fn to<T>(self) -> T::Output
240    where
241        T: InfallibleConversionTarget<S>,
242    {
243        T::convert(self.to_time())
244    }
245
246    /// Unified fallible conversion to a target scale or encoded format.
247    #[allow(private_bounds)]
248    #[inline]
249    pub fn try_to<T>(self) -> Result<T::Output, ConversionError>
250    where
251        T: ConversionTarget<S>,
252    {
253        T::try_convert(self.to_time())
254    }
255}
256
257impl<S: Scale, R> EncodedTime<S, R>
258where
259    R: RepresentationForScale<S>,
260{
261    /// Unified context-backed conversion to a target scale or encoded format.
262    #[allow(private_bounds)]
263    #[inline]
264    pub fn to_with<T>(self, ctx: &TimeContext) -> Result<T::Output, ConversionError>
265    where
266        T: ContextConversionTarget<S>,
267    {
268        T::convert_with(self.to_time_with(ctx)?, ctx)
269    }
270}
271
272/// `EncodedTime<S, JD>` convenience alias.
273pub type JulianDate<S> = EncodedTime<S, JD>;
274
275/// `EncodedTime<S, MJD>` convenience alias.
276pub type ModifiedJulianDate<S> = EncodedTime<S, MJD>;
277
278/// `EncodedTime<S, J2000s>` convenience alias.
279pub type J2000Seconds<S> = EncodedTime<S, J2000s>;
280
281/// `EncodedTime<UTC, Unix>` convenience alias.
282pub type UnixTime = EncodedTime<UTC, Unix>;
283
284/// `EncodedTime<TAI, GPS>` convenience alias.
285pub type GpsTime = EncodedTime<TAI, GPS>;
286
287macro_rules! coordinate_representation {
288    ($repr:ty, $quantity:ty, $from_time:expr, $to_time:expr) => {
289        impl<S: CoordinateScale> RepresentationForScale<S> for $repr {
290            #[inline]
291            fn try_from_time(
292                time: Time<S>,
293                _ctx: &TimeContext,
294            ) -> Result<$quantity, ConversionError> {
295                Ok(<Self as InfallibleRepresentationForScale<S>>::from_time(
296                    time,
297                ))
298            }
299
300            #[inline]
301            fn try_into_time(
302                raw: $quantity,
303                _ctx: &TimeContext,
304            ) -> Result<Time<S>, ConversionError> {
305                Ok(<Self as InfallibleRepresentationForScale<S>>::into_time(
306                    raw,
307                ))
308            }
309        }
310
311        impl<S: CoordinateScale> InfallibleRepresentationForScale<S> for $repr {
312            #[inline]
313            fn from_time(time: Time<S>) -> $quantity {
314                $from_time(time)
315            }
316
317            #[inline]
318            fn into_time(raw: $quantity) -> Time<S> {
319                $to_time(raw)
320            }
321        }
322    };
323}
324
325coordinate_representation!(
326    J2000s,
327    Second,
328    |time: Time<_>| time.raw_j2000_seconds(),
329    |raw: Second| Time::from_raw_j2000_seconds(raw).expect("finite J2000 seconds must decode")
330);
331coordinate_representation!(
332    JD,
333    Day,
334    |time: Time<_>| j2000_seconds_to_jd(time.raw_j2000_seconds()),
335    |raw: Day| Time::from_raw_j2000_seconds(jd_to_j2000_seconds(raw))
336        .expect("finite Julian date must decode")
337);
338coordinate_representation!(
339    MJD,
340    Day,
341    |time: Time<_>| j2000_seconds_to_mjd(time.raw_j2000_seconds()),
342    |raw: Day| Time::from_raw_j2000_seconds(mjd_to_j2000_seconds(raw))
343        .expect("finite Modified Julian date must decode")
344);
345
346impl RepresentationForScale<UTC> for Unix {
347    #[inline]
348    fn try_from_time(time: Time<UTC>, ctx: &TimeContext) -> Result<Second, ConversionError> {
349        time.raw_unix_seconds_with(ctx)
350    }
351
352    #[inline]
353    fn try_into_time(raw: Second, ctx: &TimeContext) -> Result<Time<UTC>, ConversionError> {
354        Time::from_raw_unix_seconds_with(raw, ctx)
355    }
356}
357
358impl RepresentationForScale<TAI> for GPS {
359    #[inline]
360    fn try_from_time(time: Time<TAI>, _ctx: &TimeContext) -> Result<Second, ConversionError> {
361        Ok(<Self as InfallibleRepresentationForScale<TAI>>::from_time(
362            time,
363        ))
364    }
365
366    #[inline]
367    fn try_into_time(raw: Second, _ctx: &TimeContext) -> Result<Time<TAI>, ConversionError> {
368        Ok(<Self as InfallibleRepresentationForScale<TAI>>::into_time(
369            raw,
370        ))
371    }
372}
373
374impl InfallibleRepresentationForScale<TAI> for GPS {
375    #[inline]
376    fn from_time(time: Time<TAI>) -> Second {
377        time.raw_gps_seconds()
378    }
379
380    #[inline]
381    fn into_time(raw: Second) -> Time<TAI> {
382        Time::from_raw_gps_seconds(raw).expect("finite GPS seconds must decode")
383    }
384}
385
386impl<S: Scale, R> From<EncodedTime<S, R>> for Time<S>
387where
388    R: InfallibleRepresentationForScale<S>,
389{
390    #[inline]
391    fn from(value: EncodedTime<S, R>) -> Self {
392        value.to_time()
393    }
394}
395
396impl<S: Scale, R> From<Time<S>> for EncodedTime<S, R>
397where
398    R: InfallibleRepresentationForScale<S>,
399{
400    #[inline]
401    fn from(value: Time<S>) -> Self {
402        Self::from_time_infallible(value)
403    }
404}
405
406// ── ConversionTarget impls for format markers ────────────────────────────────
407
408impl<S: CoordinateScale> ConversionTarget<S> for J2000s {
409    type Output = EncodedTime<S, J2000s>;
410
411    #[inline]
412    fn try_convert(src: Time<S>) -> Result<Self::Output, ConversionError> {
413        Ok(EncodedTime::from_time_infallible(src))
414    }
415}
416
417impl<S: CoordinateScale> InfallibleConversionTarget<S> for J2000s {
418    #[inline]
419    fn convert(src: Time<S>) -> Self::Output {
420        EncodedTime::from_time_infallible(src)
421    }
422}
423
424impl<S: CoordinateScale> ConversionTarget<S> for JD {
425    type Output = EncodedTime<S, JD>;
426
427    #[inline]
428    fn try_convert(src: Time<S>) -> Result<Self::Output, ConversionError> {
429        Ok(EncodedTime::from_time_infallible(src))
430    }
431}
432
433impl<S: CoordinateScale> InfallibleConversionTarget<S> for JD {
434    #[inline]
435    fn convert(src: Time<S>) -> Self::Output {
436        EncodedTime::from_time_infallible(src)
437    }
438}
439
440impl<S: CoordinateScale> ConversionTarget<S> for MJD {
441    type Output = EncodedTime<S, MJD>;
442
443    #[inline]
444    fn try_convert(src: Time<S>) -> Result<Self::Output, ConversionError> {
445        Ok(EncodedTime::from_time_infallible(src))
446    }
447}
448
449impl<S: CoordinateScale> InfallibleConversionTarget<S> for MJD {
450    #[inline]
451    fn convert(src: Time<S>) -> Self::Output {
452        EncodedTime::from_time_infallible(src)
453    }
454}
455
456impl<S> ConversionTarget<S> for Unix
457where
458    S: crate::scale::Scale + InfallibleScaleConvert<UTC>,
459{
460    type Output = EncodedTime<UTC, Unix>;
461
462    /// Snapshots the active time-data bundle at call time via
463    /// [`TimeContext::new`]. For reproducible pipelines, prefer
464    /// [`to_with::<Unix>(&ctx)`](crate::time::Time::to_with).
465    #[inline]
466    fn try_convert(src: Time<S>) -> Result<Self::Output, ConversionError> {
467        let utc = src.to_scale::<UTC>();
468        let raw = Unix::try_from_time(utc, &TimeContext::new())?;
469        Ok(EncodedTime::new_unchecked(raw))
470    }
471}
472
473impl ContextConversionTarget<UTC> for Unix {
474    type Output = EncodedTime<UTC, Unix>;
475
476    #[inline]
477    fn convert_with(src: Time<UTC>, ctx: &TimeContext) -> Result<Self::Output, ConversionError> {
478        let raw = Unix::try_from_time(src, ctx)?;
479        Ok(EncodedTime::new_unchecked(raw))
480    }
481}
482
483impl<S> ContextConversionTarget<S> for Unix
484where
485    S: crate::scale::Scale + crate::scale::conversion::ContextScaleConvert<UTC>,
486{
487    type Output = EncodedTime<UTC, Unix>;
488
489    #[inline]
490    fn convert_with(src: Time<S>, ctx: &TimeContext) -> Result<Self::Output, ConversionError> {
491        let utc = src.to_scale_with::<UTC>(ctx)?;
492        let raw = Unix::try_from_time(utc, ctx)?;
493        Ok(EncodedTime::new_unchecked(raw))
494    }
495}
496
497impl<S> ConversionTarget<S> for GPS
498where
499    S: crate::scale::Scale + InfallibleScaleConvert<TAI>,
500{
501    type Output = EncodedTime<TAI, GPS>;
502
503    #[inline]
504    fn try_convert(src: Time<S>) -> Result<Self::Output, ConversionError> {
505        Ok(Self::convert(src))
506    }
507}
508
509impl<S> InfallibleConversionTarget<S> for GPS
510where
511    S: crate::scale::Scale + InfallibleScaleConvert<TAI>,
512{
513    #[inline]
514    fn convert(src: Time<S>) -> Self::Output {
515        EncodedTime::from_time_infallible(src.to_scale::<TAI>())
516    }
517}
518
519impl<S> ContextConversionTarget<S> for GPS
520where
521    S: crate::scale::Scale + crate::scale::conversion::ContextScaleConvert<TAI>,
522{
523    type Output = EncodedTime<TAI, GPS>;
524
525    #[inline]
526    fn convert_with(src: Time<S>, ctx: &TimeContext) -> Result<Self::Output, ConversionError> {
527        let tai = src.to_scale_with::<TAI>(ctx)?;
528        Ok(EncodedTime::from_time_infallible(tai))
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::scale::TT;
536
537    #[test]
538    fn encoded_time_display_delegates_to_quantity() {
539        let jd = JulianDate::<TT>::try_new(Day::new(2_451_545.123_456_789)).unwrap();
540
541        assert_eq!(format!("{jd:.9}"), "2451545.123456789 d");
542    }
543
544    #[test]
545    fn encoded_time_lower_exp_delegates_to_quantity() {
546        let seconds = J2000Seconds::<TT>::try_new(Second::new(1_234.5)).unwrap();
547        let formatted = format!("{seconds:.2e}");
548
549        assert!(formatted.contains("e"));
550        assert!(formatted.ends_with(" s"));
551    }
552}