Skip to main content

pleiades_types/
time.rs

1//! Time primitives: [`JulianDay`], [`TimeScale`], [`TimeScaleConversion`], and [`Instant`].
2
3use core::fmt;
4use core::time::Duration;
5
6use crate::angles::Angle;
7
8/// A Julian day expressed as a floating-point day count.
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
11pub struct JulianDay(f64);
12
13impl JulianDay {
14    /// Creates a new Julian day value.
15    pub const fn from_days(days: f64) -> Self {
16        Self(days)
17    }
18
19    /// Returns the raw floating-point day count.
20    pub const fn days(self) -> f64 {
21        self.0
22    }
23
24    /// Returns a Julian day shifted by the supplied number of SI seconds.
25    ///
26    /// This is a mechanical day-count operation. It does not choose or model a
27    /// time-scale conversion policy by itself; callers must provide the offset
28    /// appropriate for the source and target scales.
29    pub fn add_seconds(self, seconds: f64) -> Self {
30        Self(self.0 + seconds / SECONDS_PER_DAY)
31    }
32}
33
34impl fmt::Display for JulianDay {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        write!(f, "JD {}", self.0)
37    }
38}
39
40/// A supported astronomical time scale.
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
43#[non_exhaustive]
44pub enum TimeScale {
45    /// Coordinated Universal Time.
46    Utc,
47    /// Universal Time 1.
48    Ut1,
49    /// Terrestrial Time.
50    Tt,
51    /// Barycentric Dynamical Time.
52    Tdb,
53}
54
55impl fmt::Display for TimeScale {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        let label = match self {
58            Self::Utc => "UTC",
59            Self::Ut1 => "UT1",
60            Self::Tt => "TT",
61            Self::Tdb => "TDB",
62        };
63        f.write_str(label)
64    }
65}
66
67/// Number of SI seconds in one Julian day.
68pub const SECONDS_PER_DAY: f64 = 86_400.0;
69
70/// J2000.0 mean obliquity of the ecliptic, degrees (IAU 1976 constant term).
71/// Single source of truth shared by the SPK ICRF→ecliptic reduction
72/// (`pleiades-jpl`), the J2000→date precession (`pleiades-apparent`), and the
73/// constant term of [`Instant::mean_obliquity`].
74pub const OBLIQUITY_J2000_DEG: f64 = 23.439_291_111_111_11;
75
76/// Error returned when a caller-provided time-scale conversion fails.
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub enum TimeScaleConversionError {
79    /// Time scale required by the conversion helper.
80    Expected {
81        /// The time scale the conversion helper required.
82        expected: TimeScale,
83        /// The time scale actually supplied by the caller.
84        actual: TimeScale,
85    },
86    /// The supplied offset was not a finite number of seconds.
87    NonFiniteOffset,
88}
89
90impl TimeScaleConversionError {
91    pub(crate) const fn expected(expected: TimeScale, actual: TimeScale) -> Self {
92        Self::Expected { expected, actual }
93    }
94
95    pub(crate) const fn non_finite_offset() -> Self {
96        Self::NonFiniteOffset
97    }
98
99    /// Returns a compact one-line rendering of the conversion failure.
100    pub fn summary_line(&self) -> String {
101        match self {
102            Self::Expected { expected, actual } => format!(
103                "time-scale conversion expected {}, got {}",
104                expected, actual
105            ),
106            Self::NonFiniteOffset => "time-scale conversion offset must be finite".to_string(),
107        }
108    }
109}
110
111impl fmt::Display for TimeScaleConversionError {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        f.write_str(&self.summary_line())
114    }
115}
116
117impl std::error::Error for TimeScaleConversionError {}
118
119/// A caller-supplied time-scale conversion policy.
120///
121/// The conversion stores the source and target time scales plus the explicit
122/// `target - source` offset in SI seconds. It does not model Delta T,
123/// leap seconds, DUT1, or relativistic TDB terms itself; it only packages the
124/// caller's chosen rule so an instant can be retagged explicitly and
125/// reproducibly. Its compact summary renders the structured field names
126/// explicitly so release-facing diagnostics do not have to infer which side of
127/// the conversion the offset applies to.
128///
129/// # Example
130///
131/// ```
132/// use pleiades_types::{Instant, JulianDay, TimeScale, TimeScaleConversion};
133///
134/// let policy = TimeScaleConversion::new(TimeScale::Ut1, TimeScale::Tt, 64.184);
135/// let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Ut1);
136/// let converted = policy.apply(instant).expect("UT1-tagged instant");
137///
138/// assert_eq!(policy.summary_line(), "source=UT1; target=TT; offset_seconds=64.184 s");
139/// assert_eq!(converted.scale, TimeScale::Tt);
140/// ```
141///
142/// ```
143/// use pleiades_types::{Instant, JulianDay, TimeScale, TimeScaleConversion};
144///
145/// let policy = TimeScaleConversion::new(TimeScale::Tdb, TimeScale::Tt, -0.001_657);
146/// let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tdb);
147///
148/// assert!(policy.validate(instant).is_ok());
149/// assert_eq!(policy.summary_line(), "source=TDB; target=TT; offset_seconds=-0.001657 s");
150/// assert_eq!(policy.validated_summary_line(instant).unwrap(), policy.summary_line());
151/// ```
152#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
153#[derive(Clone, Copy, Debug, PartialEq)]
154pub struct TimeScaleConversion {
155    /// The source time scale expected by the policy.
156    pub source: TimeScale,
157    /// The target time scale produced by the policy.
158    pub target: TimeScale,
159    /// The explicit `target - source` offset in SI seconds.
160    pub offset_seconds: f64,
161}
162
163impl TimeScaleConversion {
164    /// Creates a new caller-supplied time-scale conversion policy.
165    pub const fn new(source: TimeScale, target: TimeScale, offset_seconds: f64) -> Self {
166        Self {
167            source,
168            target,
169            offset_seconds,
170        }
171    }
172
173    /// Returns a compact one-line rendering of the caller-supplied policy.
174    pub fn summary_line(&self) -> String {
175        format!(
176            "source={}; target={}; offset_seconds={} s",
177            self.source, self.target, self.offset_seconds
178        )
179    }
180
181    /// Returns the compact summary line after validating the policy.
182    ///
183    /// This keeps the fail-closed rendering path co-located with the explicit
184    /// conversion contract when a caller wants to report the policy before
185    /// mutating the instant.
186    pub fn validated_summary_line(
187        &self,
188        instant: Instant,
189    ) -> Result<String, TimeScaleConversionError> {
190        self.validate(instant)?;
191        Ok(self.summary_line())
192    }
193
194    /// Validates the policy against a specific instant without retagging it.
195    ///
196    /// This is useful when a caller wants to preflight the explicit conversion
197    /// contract before mutating the instant itself. The source scale must match
198    /// the instant's current scale, and the offset must be finite.
199    pub fn validate(self, instant: Instant) -> Result<(), TimeScaleConversionError> {
200        if instant.scale != self.source {
201            return Err(TimeScaleConversionError::expected(
202                self.source,
203                instant.scale,
204            ));
205        }
206
207        checked_time_scale_offset(self.offset_seconds)?;
208        Ok(())
209    }
210
211    /// Applies the policy to an instant.
212    ///
213    /// This is the mutating counterpart to [`TimeScaleConversion::validate`].
214    /// The source scale must match the instant's current scale, and the offset
215    /// must be finite. Otherwise the conversion fails with the same structured
216    /// time-scale error used by the lower-level helpers.
217    pub fn apply(self, instant: Instant) -> Result<Instant, TimeScaleConversionError> {
218        self.validate(instant)?;
219        Ok(instant.with_time_scale_offset(self.target, self.offset_seconds))
220    }
221}
222
223impl fmt::Display for TimeScaleConversion {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        f.write_str(&self.summary_line())
226    }
227}
228
229pub(crate) fn checked_time_scale_offset(
230    offset_seconds: f64,
231) -> Result<f64, TimeScaleConversionError> {
232    if offset_seconds.is_finite() {
233        Ok(offset_seconds)
234    } else {
235        Err(TimeScaleConversionError::non_finite_offset())
236    }
237}
238
239/// A Julian day tagged with a time scale.
240#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
241#[derive(Clone, Copy, Debug, PartialEq)]
242pub struct Instant {
243    /// The numeric Julian day value.
244    pub julian_day: JulianDay,
245    /// The time scale used by the Julian day value.
246    pub scale: TimeScale,
247}
248
249impl Instant {
250    /// Creates a new instant from a Julian day and time scale.
251    pub const fn new(julian_day: JulianDay, scale: TimeScale) -> Self {
252        Self { julian_day, scale }
253    }
254
255    /// Returns a compact one-line rendering of the instant.
256    pub fn summary_line(&self) -> String {
257        format!("{} {}", self.julian_day, self.scale)
258    }
259
260    /// Applies a caller-supplied time-scale conversion policy.
261    ///
262    /// This helper is the generic counterpart to the source-specific
263    /// `tt_from_*` / `tdb_from_*` methods. It lets callers package the explicit
264    /// source, target, and offset choice into one typed record when they want
265    /// to keep the conversion contract alongside the instant.
266    pub fn with_time_scale_conversion(
267        self,
268        conversion: TimeScaleConversion,
269    ) -> Result<Self, TimeScaleConversionError> {
270        conversion.apply(self)
271    }
272
273    /// Validates a caller-supplied time-scale conversion policy without retagging the instant.
274    ///
275    /// This is the foundation-layer counterpart to
276    /// [`TimeScaleConversion::validate`], which lets callers preflight the same
277    /// explicit source/target/offset contract directly from an instant when
278    /// they do not yet want to mutate it.
279    ///
280    /// # Example
281    ///
282    /// ```
283    /// use pleiades_types::{Instant, JulianDay, TimeScale, TimeScaleConversion};
284    ///
285    /// let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Ut1);
286    /// let policy = TimeScaleConversion::new(TimeScale::Ut1, TimeScale::Tt, 64.184);
287    ///
288    /// assert!(instant.validate_time_scale_conversion(policy).is_ok());
289    /// ```
290    pub fn validate_time_scale_conversion(
291        self,
292        conversion: TimeScaleConversion,
293    ) -> Result<(), TimeScaleConversionError> {
294        conversion.validate(self)
295    }
296
297    /// Returns this instant with a caller-supplied offset applied and a new time
298    /// scale tag.
299    ///
300    /// The offset is expressed as `target - source` in SI seconds. For example,
301    /// callers converting UT1 to TT can pass Delta T (`TT - UT1`) and set
302    /// `target_scale` to [`TimeScale::Tt`]. This helper intentionally performs
303    /// no leap-second, DUT1, Delta T, or relativistic modeling; it only makes the
304    /// caller-provided policy explicit and reproducible. Callers should pass a
305    /// finite offset; the validated signed helpers reject non-finite values
306    /// before reaching this low-level retagging step.
307    pub fn with_time_scale_offset(self, target_scale: TimeScale, offset_seconds: f64) -> Self {
308        Self {
309            julian_day: self.julian_day.add_seconds(offset_seconds),
310            scale: target_scale,
311        }
312    }
313
314    /// Returns this instant with a caller-supplied offset applied and a new time
315    /// scale tag after validating the source scale and offset.
316    ///
317    /// This is the checked counterpart to [`Instant::with_time_scale_offset`].
318    /// It keeps the same explicit `target - source` interpretation while
319    /// rejecting non-finite offsets and mismatched source scales before the
320    /// instant is retagged.
321    ///
322    /// # Example
323    ///
324    /// ```
325    /// use pleiades_types::{Instant, JulianDay, TimeScale};
326    ///
327    /// let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Ut1);
328    /// let converted = instant
329    ///     .with_time_scale_offset_checked(TimeScale::Tt, 64.184)
330    ///     .expect("validated offset");
331    ///
332    /// assert_eq!(converted.scale, TimeScale::Tt);
333    /// ```
334    pub fn with_time_scale_offset_checked(
335        self,
336        target_scale: TimeScale,
337        offset_seconds: f64,
338    ) -> Result<Self, TimeScaleConversionError> {
339        TimeScaleConversion::new(self.scale, target_scale, offset_seconds).apply(self)
340    }
341
342    /// Returns the mean obliquity of the ecliptic for this instant.
343    ///
344    /// The value uses the shared cubic approximation currently used throughout
345    /// the workspace for precession-era obliquity values. The backends'
346    /// J2000 equatorial channel does not use it; it rotates by
347    /// [`OBLIQUITY_J2000_DEG`](crate::OBLIQUITY_J2000_DEG) instead. It is
348    /// expressed as a typed angle so callers can pass it directly into
349    /// coordinate conversion helpers.
350    pub fn mean_obliquity(self) -> Angle {
351        let t = (self.julian_day.days() - 2_451_545.0) / 36_525.0;
352        Angle::from_degrees(
353            OBLIQUITY_J2000_DEG
354                - 0.013_004_166_666_666_667 * t
355                - 0.000_000_163_888_888_888_888_88 * t * t
356                + 0.000_000_503_611_111_111_111_1 * t * t * t,
357        )
358    }
359
360    /// Converts a UT1-tagged instant to TT using caller-supplied Delta T.
361    ///
362    /// `delta_t` must be the value `TT - UT1`. Use this when validation data or
363    /// an application already has an explicit Delta T policy and wants to pass a
364    /// TT instant to backends that require TT. UTC-to-TT conversion is not
365    /// represented by this helper, because UTC also requires leap-second and
366    /// DUT1 handling outside the current type layer.
367    ///
368    /// For signed `TT - UT1` policies, use [`Instant::tt_from_ut1_signed`].
369    ///
370    /// # Example
371    ///
372    /// ```
373    /// use std::time::Duration;
374    /// use pleiades_types::{Instant, JulianDay, TimeScale};
375    ///
376    /// let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Ut1);
377    /// let converted = instant.tt_from_ut1(Duration::from_secs_f64(64.184)).expect("UT1-tagged instant");
378    ///
379    /// assert_eq!(converted.scale, TimeScale::Tt);
380    /// assert!(converted.julian_day.days() > instant.julian_day.days());
381    /// ```
382    pub fn tt_from_ut1(self, delta_t: Duration) -> Result<Self, TimeScaleConversionError> {
383        if self.scale != TimeScale::Ut1 {
384            return Err(TimeScaleConversionError::expected(
385                TimeScale::Ut1,
386                self.scale,
387            ));
388        }
389
390        Ok(self.with_time_scale_offset(TimeScale::Tt, delta_t.as_secs_f64()))
391    }
392
393    /// Converts a UT1-tagged instant to TT using a caller-supplied signed offset.
394    ///
395    /// `offset_seconds` must be the already-chosen signed `TT - UT1` offset in
396    /// SI seconds. The helper intentionally does not model leap seconds or
397    /// DUT1 by itself; it only makes a caller-supplied UT1-to-TT policy explicit
398    /// and reproducible for applications that need a TT-tagged request surface.
399    ///
400    /// # Example
401    ///
402    /// ```
403    /// use pleiades_types::{Instant, JulianDay, TimeScale};
404    ///
405    /// let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Ut1);
406    /// let converted = instant.tt_from_ut1_signed(64.184).expect("UT1-tagged instant");
407    ///
408    /// assert_eq!(converted.scale, TimeScale::Tt);
409    /// assert!(converted.julian_day.days() > instant.julian_day.days());
410    /// ```
411    pub fn tt_from_ut1_signed(self, offset_seconds: f64) -> Result<Self, TimeScaleConversionError> {
412        if self.scale != TimeScale::Ut1 {
413            return Err(TimeScaleConversionError::expected(
414                TimeScale::Ut1,
415                self.scale,
416            ));
417        }
418
419        let offset_seconds = checked_time_scale_offset(offset_seconds)?;
420
421        Ok(self.with_time_scale_offset(TimeScale::Tt, offset_seconds))
422    }
423
424    /// Converts a UTC-tagged instant to TT using caller-supplied offset.
425    ///
426    /// `delta_t` must be the already-chosen `TT - UTC` offset in SI seconds.
427    /// The helper intentionally does not model leap seconds or DUT1 by itself;
428    /// it only makes a caller-supplied UTC-to-TT policy explicit and
429    /// reproducible for applications that start from civil time.
430    ///
431    /// For signed `TT - UTC` policies, use [`Instant::tt_from_utc_signed`].
432    pub fn tt_from_utc(self, delta_t: Duration) -> Result<Self, TimeScaleConversionError> {
433        if self.scale != TimeScale::Utc {
434            return Err(TimeScaleConversionError::expected(
435                TimeScale::Utc,
436                self.scale,
437            ));
438        }
439
440        Ok(self.with_time_scale_offset(TimeScale::Tt, delta_t.as_secs_f64()))
441    }
442
443    /// Converts a UTC-tagged instant to TT using a caller-supplied signed offset.
444    ///
445    /// `offset_seconds` must be the already-chosen signed `TT - UTC` offset in
446    /// SI seconds. The helper intentionally does not model leap seconds or
447    /// DUT1 by itself; it only makes a caller-supplied UTC-to-TT policy explicit
448    /// and reproducible for applications that need a TT-tagged request surface.
449    ///
450    /// # Example
451    ///
452    /// ```
453    /// use pleiades_types::{Instant, JulianDay, TimeScale};
454    ///
455    /// let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Utc);
456    /// let converted = instant.tt_from_utc_signed(64.184).expect("UTC-tagged instant");
457    ///
458    /// assert_eq!(converted.scale, TimeScale::Tt);
459    /// assert!(converted.julian_day.days() > instant.julian_day.days());
460    /// ```
461    pub fn tt_from_utc_signed(self, offset_seconds: f64) -> Result<Self, TimeScaleConversionError> {
462        if self.scale != TimeScale::Utc {
463            return Err(TimeScaleConversionError::expected(
464                TimeScale::Utc,
465                self.scale,
466            ));
467        }
468
469        let offset_seconds = checked_time_scale_offset(offset_seconds)?;
470
471        Ok(self.with_time_scale_offset(TimeScale::Tt, offset_seconds))
472    }
473
474    /// Converts a TT-tagged instant to TDB using a caller-supplied offset.
475    ///
476    /// `offset` must be the already-chosen `TDB - TT` offset in SI seconds.
477    /// For signed TDB-TT policies, use [`Instant::tdb_from_tt_signed`]. The
478    /// helper intentionally does not model relativistic terms by itself; it
479    /// only makes a caller-supplied TT-to-TDB policy explicit and reproducible
480    /// for applications that need a TDB-tagged request surface.
481    pub fn tdb_from_tt(self, offset: Duration) -> Result<Self, TimeScaleConversionError> {
482        if self.scale != TimeScale::Tt {
483            return Err(TimeScaleConversionError::expected(
484                TimeScale::Tt,
485                self.scale,
486            ));
487        }
488
489        Ok(self.with_time_scale_offset(TimeScale::Tdb, offset.as_secs_f64()))
490    }
491
492    /// Converts a TT-tagged instant to TDB using a caller-supplied signed offset.
493    ///
494    /// `offset_seconds` must be the already-chosen signed `TDB - TT` offset in
495    /// SI seconds. The helper intentionally does not model relativistic terms by
496    /// itself; it only makes a caller-supplied TT-to-TDB policy explicit and
497    /// reproducible for applications that need a TDB-tagged request surface.
498    pub fn tdb_from_tt_signed(self, offset_seconds: f64) -> Result<Self, TimeScaleConversionError> {
499        if self.scale != TimeScale::Tt {
500            return Err(TimeScaleConversionError::expected(
501                TimeScale::Tt,
502                self.scale,
503            ));
504        }
505
506        let offset_seconds = checked_time_scale_offset(offset_seconds)?;
507
508        Ok(self.with_time_scale_offset(TimeScale::Tdb, offset_seconds))
509    }
510
511    /// Converts a TDB-tagged instant to TT using a caller-supplied signed offset.
512    ///
513    /// `offset_seconds` must be the already-chosen signed `TT - TDB` offset in
514    /// SI seconds. The helper intentionally does not model relativistic terms by
515    /// itself; it only makes a caller-supplied TDB-to-TT policy explicit and
516    /// reproducible for applications that need a TT-tagged request surface.
517    pub fn tt_from_tdb(self, offset_seconds: f64) -> Result<Self, TimeScaleConversionError> {
518        if self.scale != TimeScale::Tdb {
519            return Err(TimeScaleConversionError::expected(
520                TimeScale::Tdb,
521                self.scale,
522            ));
523        }
524
525        let offset_seconds = checked_time_scale_offset(offset_seconds)?;
526
527        Ok(self.with_time_scale_offset(TimeScale::Tt, offset_seconds))
528    }
529
530    /// Converts a TDB-tagged instant to TT using a caller-supplied signed offset.
531    ///
532    /// This is an explicit alias for [`Instant::tt_from_tdb`] that mirrors the
533    /// signed helper naming used for the other time-scale conversion policies.
534    ///
535    /// # Example
536    ///
537    /// ```
538    /// use pleiades_types::{Instant, JulianDay, TimeScale};
539    ///
540    /// let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tdb);
541    /// let converted = instant.tt_from_tdb_signed(-0.001_657).expect("TDB-tagged instant");
542    ///
543    /// assert_eq!(converted.scale, TimeScale::Tt);
544    /// assert!(converted.julian_day.days() < instant.julian_day.days());
545    /// ```
546    pub fn tt_from_tdb_signed(self, offset_seconds: f64) -> Result<Self, TimeScaleConversionError> {
547        self.tt_from_tdb(offset_seconds)
548    }
549
550    /// Converts a UT1-tagged instant to TDB using caller-supplied TT-UT1 and
551    /// TDB-TT offsets.
552    ///
553    /// `tt_offset` must be the already-chosen `TT - UT1` offset in SI
554    /// seconds. `tdb_offset` must be the already-chosen `TDB - TT` offset in
555    /// SI seconds. The helper intentionally does not model leap seconds,
556    /// DUT1, or relativistic terms by itself; it only composes caller-supplied
557    /// policy steps into a reproducible TDB-tagged instant.
558    pub fn tdb_from_ut1(
559        self,
560        tt_offset: Duration,
561        tdb_offset: Duration,
562    ) -> Result<Self, TimeScaleConversionError> {
563        let tt = self.tt_from_ut1(tt_offset)?;
564        tt.tdb_from_tt(tdb_offset)
565    }
566
567    /// Converts a UT1-tagged instant to TDB using caller-supplied TT-UT1 and
568    /// signed TDB-TT offsets.
569    ///
570    /// `tt_offset` must be the already-chosen `TT - UT1` offset in SI
571    /// seconds. `tdb_offset_seconds` must be the already-chosen signed
572    /// `TDB - TT` offset in SI seconds. The helper intentionally does not
573    /// model leap seconds, DUT1, or relativistic terms by itself; it only
574    /// composes caller-supplied policy steps into a reproducible TDB-tagged
575    /// instant.
576    ///
577    /// # Example
578    ///
579    /// ```
580    /// use std::time::Duration;
581    /// use pleiades_types::{Instant, JulianDay, TimeScale};
582    ///
583    /// let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Ut1);
584    /// let converted = instant
585    ///     .tdb_from_ut1_signed(Duration::from_secs_f64(64.184), -0.001_657)
586    ///     .expect("UT1-tagged instant");
587    ///
588    /// assert_eq!(converted.scale, TimeScale::Tdb);
589    /// assert!(converted.julian_day.days() > instant.julian_day.days());
590    /// ```
591    pub fn tdb_from_ut1_signed(
592        self,
593        tt_offset: Duration,
594        tdb_offset_seconds: f64,
595    ) -> Result<Self, TimeScaleConversionError> {
596        let tt = self.tt_from_ut1(tt_offset)?;
597        tt.tdb_from_tt_signed(tdb_offset_seconds)
598    }
599
600    /// Converts a UTC-tagged instant to TDB using caller-supplied TT-UTC and
601    /// TDB-TT offsets.
602    ///
603    /// `tt_offset` must be the already-chosen `TT - UTC` offset in SI seconds.
604    /// `tdb_offset` must be the already-chosen `TDB - TT` offset in SI
605    /// seconds. The helper intentionally does not model leap seconds, DUT1, or
606    /// relativistic terms by itself; it only composes caller-supplied policy
607    /// steps into a reproducible TDB-tagged instant.
608    pub fn tdb_from_utc(
609        self,
610        tt_offset: Duration,
611        tdb_offset: Duration,
612    ) -> Result<Self, TimeScaleConversionError> {
613        let tt = self.tt_from_utc(tt_offset)?;
614        tt.tdb_from_tt(tdb_offset)
615    }
616
617    /// Converts a UTC-tagged instant to TDB using caller-supplied TT-UTC and
618    /// signed TDB-TT offsets.
619    ///
620    /// `tt_offset` must be the already-chosen `TT - UTC` offset in SI seconds.
621    /// `tdb_offset_seconds` must be the already-chosen signed `TDB - TT`
622    /// offset in SI seconds. The helper intentionally does not model leap
623    /// seconds, DUT1, or relativistic terms by itself; it only composes
624    /// caller-supplied policy steps into a reproducible TDB-tagged instant.
625    ///
626    /// # Example
627    ///
628    /// ```
629    /// use std::time::Duration;
630    /// use pleiades_types::{Instant, JulianDay, TimeScale};
631    ///
632    /// let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Utc);
633    /// let converted = instant
634    ///     .tdb_from_utc_signed(Duration::from_secs_f64(64.184), -0.001_657)
635    ///     .expect("UTC-tagged instant");
636    ///
637    /// assert_eq!(converted.scale, TimeScale::Tdb);
638    /// assert!(converted.julian_day.days() > instant.julian_day.days());
639    /// ```
640    pub fn tdb_from_utc_signed(
641        self,
642        tt_offset: Duration,
643        tdb_offset_seconds: f64,
644    ) -> Result<Self, TimeScaleConversionError> {
645        let tt = self.tt_from_utc(tt_offset)?;
646        tt.tdb_from_tt_signed(tdb_offset_seconds)
647    }
648}
649
650impl fmt::Display for Instant {
651    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
652        f.write_str(&self.summary_line())
653    }
654}