Skip to main content

tempoch_core/
coord.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! Strongly-typed raw time coordinates.
5//!
6//! [`Coord<S, F>`] is an affine *point* in time on scale `S` (TT, TAI, UTC,
7//! …) in format `F` (JD, MJD, J2000 seconds, Unix, GPS, …). [`Offset<S, F>`]
8//! is its associated displacement vector.
9//!
10//! Together, these two types let the crate name *and type-check* values that
11//! used to circulate as bare `qtty::Day` / `qtty::Second`. For example,
12//! `Coord<TT, JD>` is statically distinct from `Coord<UTC, JD>`, so the
13//! compiler now rejects mistakes like reusing a UTC-axis Julian Date as a TT
14//! one.
15//!
16//! The type parameter order `<S, F>` (Scale first, Format second) mirrors
17//! [`EncodedTime<S, F>`](crate::EncodedTime) for consistency.
18//!
19//! # Affine semantics
20//!
21//! - `Coord - Coord -> Offset`
22//! - `Coord + Offset -> Coord`
23//! - `Coord - Offset -> Coord`
24//! - `Offset + Offset -> Offset`
25//! - `Offset - Offset -> Offset`
26//! - `-Offset -> Offset`
27//!
28//! Adding two coordinates is intentionally not modeled — averaging or summing
29//! instants in the same coordinate system is not a primitive operation here.
30//!
31//! # Interop with [`EncodedTime`]
32//!
33//! `Coord<S, F>` and [`EncodedTime<S, F>`](crate::EncodedTime) carry the
34//! same information (a typed quantity, a scale, and a format). Conversion in
35//! both directions is zero-cost via [`From`] / [`Into`]. Use `Coord` for raw
36//! coordinate arithmetic and constants; use `EncodedTime` for the high-level
37//! `to_time*` / `to::<Target>()` conversion machinery.
38
39use core::fmt;
40use core::marker::PhantomData;
41use core::ops::{Add, Neg, Sub};
42
43use crate::error::ConversionError;
44use crate::format::{EncodedTime, TimeFormat};
45use crate::scale::Scale;
46use qtty::Quantity;
47
48/// A typed time coordinate on scale `S` in format `F`.
49///
50/// `Coord<S, F>` is an affine point. To shift it, add an [`Offset<S, F>`].
51/// To take the directed distance between two coordinates, subtract them.
52///
53/// `Coord` mirrors [`EncodedTime`] but is intentionally smaller in scope: it
54/// only exposes raw-quantity access and affine arithmetic. The
55/// `EncodedTime` API (`to_time`, `to::<Target>()`, …) is reachable through
56/// the `From`/`Into` conversion below.
57pub struct Coord<S: Scale, F: TimeFormat> {
58    raw: Quantity<F::Unit>,
59    _marker: PhantomData<fn() -> S>,
60}
61
62/// A typed displacement between two [`Coord<S, F>`] values.
63pub struct Offset<S: Scale, F: TimeFormat> {
64    raw: Quantity<F::Unit>,
65    _marker: PhantomData<fn() -> S>,
66}
67
68// ── Common ZST plumbing (Copy/Clone/PartialEq/PartialOrd/Hash/Debug) ─────
69
70macro_rules! impl_zst_plumbing {
71    ($ty:ident, $kind:literal) => {
72        impl<S: Scale, F: TimeFormat> Copy for $ty<S, F> {}
73
74        impl<S: Scale, F: TimeFormat> Clone for $ty<S, F> {
75            #[inline]
76            fn clone(&self) -> Self {
77                *self
78            }
79        }
80
81        impl<S: Scale, F: TimeFormat> PartialEq for $ty<S, F> {
82            #[inline]
83            fn eq(&self, other: &Self) -> bool {
84                self.raw == other.raw
85            }
86        }
87
88        impl<S: Scale, F: TimeFormat> PartialOrd for $ty<S, F> {
89            #[inline]
90            fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
91                self.raw.partial_cmp(&other.raw)
92            }
93        }
94
95        impl<S: Scale, F: TimeFormat> fmt::Debug for $ty<S, F> {
96            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97                f.debug_struct($kind)
98                    .field("scale", &S::NAME)
99                    .field("format", &F::NAME)
100                    .field("raw", &self.raw)
101                    .finish()
102            }
103        }
104
105        impl<S: Scale, F: TimeFormat> fmt::Display for $ty<S, F>
106        where
107            qtty::Quantity<F::Unit>: fmt::Display,
108        {
109            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110                fmt::Display::fmt(&self.raw, f)
111            }
112        }
113
114        impl<S: Scale, F: TimeFormat> fmt::LowerExp for $ty<S, F>
115        where
116            qtty::Quantity<F::Unit>: fmt::LowerExp,
117        {
118            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119                fmt::LowerExp::fmt(&self.raw, f)
120            }
121        }
122
123        impl<S: Scale, F: TimeFormat> fmt::UpperExp for $ty<S, F>
124        where
125            qtty::Quantity<F::Unit>: fmt::UpperExp,
126        {
127            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128                fmt::UpperExp::fmt(&self.raw, f)
129            }
130        }
131
132        impl<S: Scale, F: TimeFormat> $ty<S, F> {
133            /// Wrap a raw quantity without checking finiteness.
134            ///
135            /// Provided for `const` contexts such as crate-level constants.
136            /// The caller is responsible for passing a finite value.
137            #[inline]
138            pub const fn from_raw_unchecked(raw: Quantity<F::Unit>) -> Self {
139                Self {
140                    raw,
141                    _marker: PhantomData,
142                }
143            }
144
145            /// Return the underlying typed quantity.
146            #[inline]
147            pub const fn raw(self) -> Quantity<F::Unit> {
148                self.raw
149            }
150
151            /// Alias for [`Self::raw`].
152            #[inline]
153            pub const fn quantity(self) -> Quantity<F::Unit> {
154                self.raw
155            }
156        }
157    };
158}
159
160impl_zst_plumbing!(Coord, "Coord");
161impl_zst_plumbing!(Offset, "Offset");
162
163// ── Checked constructors ─────────────────────────────────────────────────
164
165impl<S: Scale, F: TimeFormat> Coord<S, F> {
166    /// Build a coordinate from a typed quantity, validating finiteness.
167    #[inline]
168    pub fn try_new(raw: Quantity<F::Unit>) -> Result<Self, ConversionError> {
169        if raw.is_finite() {
170            Ok(Self::from_raw_unchecked(raw))
171        } else {
172            Err(ConversionError::NonFinite)
173        }
174    }
175}
176
177impl<S: Scale, F: TimeFormat> Offset<S, F> {
178    /// Build an offset from a typed quantity, validating finiteness.
179    #[inline]
180    pub fn try_new(raw: Quantity<F::Unit>) -> Result<Self, ConversionError> {
181        if raw.is_finite() {
182            Ok(Self::from_raw_unchecked(raw))
183        } else {
184            Err(ConversionError::NonFinite)
185        }
186    }
187
188    /// The zero offset on this `(scale, format)` pair.
189    #[inline]
190    pub fn zero() -> Self
191    where
192        Quantity<F::Unit>: Default,
193    {
194        Self::from_raw_unchecked(Quantity::<F::Unit>::default())
195    }
196}
197
198// ── Affine arithmetic ────────────────────────────────────────────────────
199
200impl<S: Scale, F: TimeFormat> Sub for Coord<S, F>
201where
202    Quantity<F::Unit>: Sub<Output = Quantity<F::Unit>>,
203{
204    type Output = Offset<S, F>;
205
206    #[inline]
207    fn sub(self, rhs: Self) -> Self::Output {
208        Offset::from_raw_unchecked(self.raw - rhs.raw)
209    }
210}
211
212impl<S: Scale, F: TimeFormat> Add<Offset<S, F>> for Coord<S, F>
213where
214    Quantity<F::Unit>: Add<Output = Quantity<F::Unit>>,
215{
216    type Output = Coord<S, F>;
217
218    #[inline]
219    fn add(self, rhs: Offset<S, F>) -> Self::Output {
220        Coord::from_raw_unchecked(self.raw + rhs.raw)
221    }
222}
223
224impl<S: Scale, F: TimeFormat> Sub<Offset<S, F>> for Coord<S, F>
225where
226    Quantity<F::Unit>: Sub<Output = Quantity<F::Unit>>,
227{
228    type Output = Coord<S, F>;
229
230    #[inline]
231    fn sub(self, rhs: Offset<S, F>) -> Self::Output {
232        Coord::from_raw_unchecked(self.raw - rhs.raw)
233    }
234}
235
236impl<S: Scale, F: TimeFormat> Add for Offset<S, F>
237where
238    Quantity<F::Unit>: Add<Output = Quantity<F::Unit>>,
239{
240    type Output = Self;
241
242    #[inline]
243    fn add(self, rhs: Self) -> Self::Output {
244        Self::from_raw_unchecked(self.raw + rhs.raw)
245    }
246}
247
248impl<S: Scale, F: TimeFormat> Sub for Offset<S, F>
249where
250    Quantity<F::Unit>: Sub<Output = Quantity<F::Unit>>,
251{
252    type Output = Self;
253
254    #[inline]
255    fn sub(self, rhs: Self) -> Self::Output {
256        Self::from_raw_unchecked(self.raw - rhs.raw)
257    }
258}
259
260impl<S: Scale, F: TimeFormat> Neg for Offset<S, F>
261where
262    Quantity<F::Unit>: Neg<Output = Quantity<F::Unit>>,
263{
264    type Output = Self;
265
266    #[inline]
267    fn neg(self) -> Self::Output {
268        Self::from_raw_unchecked(-self.raw)
269    }
270}
271
272// ── Interop with EncodedTime ────────────────────────────────────────────
273
274impl<S: Scale, F: TimeFormat> From<Coord<S, F>> for EncodedTime<S, F> {
275    #[inline]
276    fn from(value: Coord<S, F>) -> Self {
277        EncodedTime::<S, F>::from_raw_unchecked(value.raw)
278    }
279}
280
281impl<S: Scale, F: TimeFormat> From<EncodedTime<S, F>> for Coord<S, F> {
282    #[inline]
283    fn from(value: EncodedTime<S, F>) -> Self {
284        Coord::<S, F>::from_raw_unchecked(value.raw())
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::format::{JD, MJD};
292    use crate::scale::{TT, UTC};
293    use qtty::Day;
294
295    #[test]
296    fn coord_round_trip_with_encoded_time() {
297        let c = Coord::<TT, JD>::try_new(Day::new(2_451_545.5)).unwrap();
298        let e: EncodedTime<TT, JD> = c.into();
299        let back: Coord<TT, JD> = e.into();
300        assert_eq!(c, back);
301    }
302
303    #[test]
304    fn coord_minus_coord_yields_offset() {
305        let a = Coord::<TT, JD>::from_raw_unchecked(Day::new(2_451_545.5));
306        let b = Coord::<TT, JD>::from_raw_unchecked(Day::new(2_451_545.0));
307        let v: Offset<TT, JD> = a - b;
308        assert_eq!(v.raw(), Day::new(0.5));
309    }
310
311    #[test]
312    fn coord_plus_offset_yields_coord() {
313        let a = Coord::<TT, JD>::from_raw_unchecked(Day::new(2_451_545.0));
314        let v = Offset::<TT, JD>::from_raw_unchecked(Day::new(1.5));
315        let b = a + v;
316        assert_eq!(b.raw(), Day::new(2_451_546.5));
317    }
318
319    #[test]
320    fn offset_arithmetic() {
321        let v = Offset::<TT, JD>::from_raw_unchecked(Day::new(1.0));
322        let w = Offset::<TT, JD>::from_raw_unchecked(Day::new(0.25));
323        assert_eq!((v + w).raw(), Day::new(1.25));
324        assert_eq!((v - w).raw(), Day::new(0.75));
325        assert_eq!((-v).raw(), Day::new(-1.0));
326    }
327
328    #[test]
329    fn try_new_rejects_non_finite() {
330        let nan = Coord::<TT, JD>::try_new(Day::new(f64::NAN));
331        assert!(matches!(nan, Err(ConversionError::NonFinite)));
332        let inf = Offset::<UTC, MJD>::try_new(Day::new(f64::INFINITY));
333        assert!(matches!(inf, Err(ConversionError::NonFinite)));
334    }
335
336    #[test]
337    fn debug_includes_scale_and_format() {
338        let c = Coord::<TT, JD>::from_raw_unchecked(Day::new(2_451_545.0));
339        let dbg = format!("{c:?}");
340        assert!(dbg.contains("TT"));
341        assert!(dbg.contains("JD"));
342    }
343
344    #[test]
345    fn display_delegates_to_quantity() {
346        let c = Coord::<TT, JD>::from_raw_unchecked(Day::new(2_451_545.5));
347        assert_eq!(format!("{c:.1}"), "2451545.5 d");
348    }
349
350    #[test]
351    fn coord_scale_phantom_prevents_mixing() {
352        fn accept_tt_jd(c: Coord<TT, JD>) -> Day {
353            c.raw()
354        }
355        fn accept_utc_jd(c: Coord<UTC, JD>) -> Day {
356            c.raw()
357        }
358
359        let tt = Coord::<TT, JD>::from_raw_unchecked(Day::new(2_451_545.0));
360        let utc = Coord::<UTC, JD>::from_raw_unchecked(Day::new(2_451_545.0));
361
362        let _ = accept_tt_jd(tt);
363        let _ = accept_utc_jd(utc);
364    }
365
366    #[test]
367    fn coord_quantity_is_alias_for_raw() {
368        let c = Coord::<TT, JD>::from_raw_unchecked(Day::new(2_451_545.5));
369        assert_eq!(c.raw(), c.quantity());
370    }
371
372    #[test]
373    fn offset_quantity_is_alias_for_raw() {
374        let v = Offset::<TT, JD>::from_raw_unchecked(Day::new(0.5));
375        assert_eq!(v.raw(), v.quantity());
376    }
377
378    #[test]
379    fn coord_minus_offset_yields_coord() {
380        let a = Coord::<TT, JD>::from_raw_unchecked(Day::new(2_451_546.0));
381        let v = Offset::<TT, JD>::from_raw_unchecked(Day::new(1.0));
382        let b = a - v;
383        assert_eq!(b.raw(), Day::new(2_451_545.0));
384    }
385
386    #[test]
387    fn coord_lower_exp_delegates_to_quantity() {
388        let c = Coord::<TT, JD>::from_raw_unchecked(Day::new(2_451_545.5));
389        assert_eq!(format!("{c:.2e}"), format!("{:.2e}", c.raw()));
390    }
391
392    #[test]
393    fn coord_upper_exp_delegates_to_quantity() {
394        let c = Coord::<TT, JD>::from_raw_unchecked(Day::new(2_451_545.5));
395        assert_eq!(format!("{c:.2E}"), format!("{:.2E}", c.raw()));
396    }
397
398    #[test]
399    fn offset_lower_exp_delegates_to_quantity() {
400        let v = Offset::<TT, JD>::from_raw_unchecked(Day::new(1.5));
401        assert_eq!(format!("{v:.2e}"), format!("{:.2e}", v.raw()));
402    }
403
404    #[test]
405    fn offset_upper_exp_delegates_to_quantity() {
406        let v = Offset::<TT, JD>::from_raw_unchecked(Day::new(1.5));
407        assert_eq!(format!("{v:.2E}"), format!("{:.2E}", v.raw()));
408    }
409
410    #[test]
411    fn offset_debug_includes_scale_and_format() {
412        let v = Offset::<TT, JD>::from_raw_unchecked(Day::new(1.0));
413        let dbg = format!("{v:?}");
414        assert!(dbg.contains("TT"));
415        assert!(dbg.contains("JD"));
416    }
417}