Skip to main content

deep_time/dt/
gregorian.rs

1use crate::{
2    ATTOS_PER_SEC, Dt, JD_2000_2_451_545, SEC_PER_DAY, Scale, Weekday, YmdHms, utc::IsLeapSec,
3};
4
5impl Dt {
6    pub(crate) const DAYS_IN_GREGORIAN_MONTHS: [u8; 12] =
7        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
8
9    // pub(crate) const DAYS_IN_GREGORIAN_MONTHS_LEAP_YR: [u8; 12] =
10    //     [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
11
12    /// Returns the calendar date and time for this instant.
13    ///
14    /// Converts to this [`Dt`]s `target` time scale using the internal current
15    /// `scale` before producing a result.
16    ///
17    /// ## Returns
18    ///
19    /// A [`YmdHms`] containing:
20    ///
21    /// - `yr`, `mo`, `day` — calendar date
22    /// - `hr` (0–23), `min` (0–59), `sec` (0–60)
23    /// - `attos` — fractional second in attoseconds (`0 ≤ attos < 10¹⁸`)
24    /// - `scale` — time scale that the object is in
25    ///
26    /// ## Leap-second handling
27    ///
28    /// If:
29    ///
30    /// - The [`Dt`]'s `target` time scale is one that uses leap seconds
31    ///   (`UTC`, `UtcSpice`, or `UtcHist`)
32    /// - The instant falls exactly on a leap second
33    /// - The objects current time scale is **not** UTC
34    ///
35    /// Then the returned `sec` will be `60`. In every other case `sec` is in the range
36    /// `0..=59`.
37    ///
38    /// The implementation converts internally to TAI before checking leap-second status.
39    ///
40    /// ## Range
41    ///
42    /// Supports the full range of [`Dt`].
43    ///
44    /// ## Examples
45    ///
46    /// ```rust
47    /// use deep_time::{Dt, Scale};
48    ///
49    /// // `from_ymd` always returns a TAI instant
50    /// let dt = Dt::from_ymd(2024, 6, 15, Scale::UTC, 12, 30, 45, 0);
51    /// let ymd = dt.to_ymd();
52    ///
53    /// assert_eq!(ymd.yr(), 2024);
54    /// assert_eq!(ymd.mo(), 6);
55    /// assert_eq!(ymd.day(), 15);
56    /// assert_eq!(ymd.hr(), 12);
57    /// assert_eq!(ymd.min(), 30);
58    /// assert_eq!(ymd.sec(), 45);
59    /// assert!(ymd.attos() == 0);
60    /// ```
61    ///
62    /// ## See also
63    ///
64    /// - [`Dt::from_ymd`](#method.from_ymd)
65    /// - [`from_ymd!`](../macro.from_ymd.html)
66    pub const fn to_ymd(&self) -> YmdHms {
67        // Whole seconds since 2000-01-01 12:00 (library zero)
68        // i128 because an i64 second count is not always large enough for every Dt
69        let on_target = self.to(self.target);
70        let sec_from_j2000 = on_target.to_sec_floor();
71        let frac = on_target.to_sec_ufrac();
72
73        // Library zero is noon, so add 12 hours before splitting into day_offset
74        // (days after 2000-01-01) and tod (hour/min/sec as seconds past midnight)
75        let since_midnight_j2000 = sec_from_j2000.saturating_add(43_200);
76        let day_offset = since_midnight_j2000.div_euclid(SEC_PER_DAY);
77        let tod = since_midnight_j2000 - day_offset * SEC_PER_DAY;
78        let (yr, mo, day) =
79            Self::jd_to_ymd(JD_2000_2_451_545.saturating_add(Self::to_i64(day_offset)));
80
81        let hr = (tod / 3600) as u8;
82        let min = ((tod % 3600) / 60) as u8;
83        let mut sec = (tod % 60) as u8;
84        if self.target.uses_leap_seconds()
85            && let Some(i) = self.to_tai().leap_sec(false)
86            && matches!(i.is_leap_sec, IsLeapSec::Add)
87        {
88            sec += 1
89        }
90
91        YmdHms {
92            yr,
93            mo,
94            day,
95            hr,
96            min,
97            sec,
98            attos: frac,
99            dt: *self,
100        }
101    }
102
103    /// Creates a **TAI** [`Dt`] from a proleptic gregorian date which is assumed to be on
104    /// the provided time scale.
105    ///
106    /// - Equivalent to converting to `TAI` for the provided date. This means for example that
107    ///   when using `Scale::UTC` leap seconds are potentially added to the returned [`Dt`].
108    /// - The returned [`Dt`] will have its `scale` field set to `TAI` and its `target` field
109    ///   set to the provided time scale argument from this fn. This makes functions such as
110    ///   [`Dt::to_ymd`](#method.to_ymd) more ergonomic.
111    ///
112    /// All input components are clamped to their valid ranges:
113    /// - `mo`   → 1..=12 **1 based**
114    /// - `day`  → 1..=31 **1 based**
115    /// - `hr`   → 0..=23 **0 based**
116    /// - `min`  → 0..=59 **0 based**
117    /// - `sec`  → 0..=60 **0 based** (permits leap seconds)
118    /// - `attos` → 10¹⁸ **0 based** fractional seconds
119    ///   (clamped to under 1 second)
120    ///
121    /// ## Examples
122    ///
123    /// ```rust
124    /// # #[cfg(any(feature = "jiff-tz-bundle", feature = "jiff-tz"))]
125    /// # {
126    /// use deep_time::{Dt, Lang, Scale};
127    ///
128    /// // library zero is 2000-01-01 noon TAI
129    /// let tai = Dt::from_ymd(2000, 1, 1, Scale::TAI, 12, 0, 0, 0);
130    /// assert_eq!(tai, Dt::ZERO);
131    ///
132    /// // utc noon
133    /// let utc = Dt::from_ymd(2000, 1, 1, Scale::UTC, 12, 0, 0, 0);
134    /// // output with timezone requires jiff-tz feature
135    /// // because from_ymd used Scale::UTC, the output is converted
136    /// // back to UTC before being offset by the timezone
137    /// let s = utc.to_str_in_tz("%A, %B %d, %Y %H:%M:%S %Q", "America/New_York", Lang::En).unwrap();
138    /// assert_eq!(s, "Saturday, January 01, 2000 07:00:00 America/New_York");
139    /// # }
140    /// ```
141    ///
142    /// ## See also
143    ///
144    /// - [`Dt::to_ymd`](#method.to_ymd)
145    /// - [`from_ymd!`](../macro.from_ymd.html)
146    pub const fn from_ymd(
147        yr: i64,
148        mo: u8,
149        day: u8,
150        scale: Scale,
151        hr: u8,
152        min: u8,
153        sec: u8,
154        attos: u64,
155    ) -> Dt {
156        let (mo, day, hr, min, sec) = Dt::clamp_mdhms(yr, mo, day, hr, min, sec);
157        let attos = Dt::clamp_u64(attos, 0, ATTOS_PER_SEC - 1);
158
159        let sec_is_60 = sec == 60;
160        let s = if sec_is_60 { 59 } else { sec };
161
162        // Whole seconds since 2000-01-01 12:00
163        // i128 because days × 86400 does not always fit in i64 for far-away years
164        let jd = Self::ymd_to_jd(yr, mo, day);
165        let days_since_j2000 = (jd as i128).saturating_sub(JD_2000_2_451_545 as i128);
166        let seconds_from_noon = (hr as i128 - 12) * 3600 + (min as i128) * 60 + (s as i128);
167        let total_sec = days_since_j2000
168            .saturating_mul(SEC_PER_DAY)
169            .saturating_add(seconds_from_noon);
170
171        let t = Dt::from_sec_and_frac(total_sec, attos as i128, scale, scale).to_tai();
172        if sec_is_60 && scale.uses_leap_seconds() {
173            // leap_sec_using_sec64 takes i64; the table only has modern dates
174            match Self::leap_sec_using_sec64(Self::to_i64(total_sec.saturating_add(1)), true) {
175                Some(i) if matches!(i.is_leap_sec, IsLeapSec::Add) => t.add_sec(1),
176                _ => t,
177            }
178        } else {
179            t
180        }
181    }
182
183    /// Converts a Julian Day Number (JD) to a proleptic Gregorian calendar date.
184    ///
185    /// - Returns `(year, month, day)` where `month` ∈ [1, 12] and `day` ∈ [1, 31].
186    /// - Inverse of [`Dt::ymd_to_jd`](#method.ymd_to_jd).
187    pub const fn jd_to_ymd(jd: i64) -> (i64, u8, u8) {
188        // Epoch shift can exit i64 near i64::MIN; add 12 eras and fix the year.
189        let (z, year_adj) = match jd.checked_sub(1_721_120) {
190            Some(z) => (z, 0i64),
191            None => (jd + 32_044, -4_800i64),
192        };
193
194        // Floored era index. Avoid `z - 146096` (overflows near i64::MIN).
195        let era = if z >= 0 {
196            z / 146097
197        } else {
198            let q = z / 146097;
199            if z % 146097 == 0 { q } else { q - 1 }
200        };
201        // Widening mul so `era * 146097` cannot wrap for extreme `z`.
202        let doe = (z as i128 - era as i128 * 146097) as i64; // [0, 146096]
203        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
204        let y = yoe + era * 400;
205        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
206        let mp = (5 * doy + 2) / 153; // [0, 11]
207        let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
208        let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
209        let yr = y + if m <= 2 { 1 } else { 0 };
210
211        (yr + year_adj, m as u8, d as u8)
212    }
213
214    /// Computes the Julian Day Number (JD) for a proleptic Gregorian calendar date at noon UT.
215    /// This is the inverse of [`jd_to_ymd`](#method.jd_to_ymd).
216    ///
217    /// ## Arguments
218    ///
219    /// * `yr`  - Year (any `i64`; proleptic Gregorian)
220    /// * `mo` - Month (**1-based**: `1` = January, `2` = February, ..., `12` = December)
221    /// * `day`   - Day of the month (**1-based**: `1` = first day of the month)
222    ///
223    /// ## Notes
224    ///
225    /// - This function expects **1 based** `mo` and `day`. Passing `mo = 0` or `day = 0` (or other
226    ///   out-of-range values) will produce incorrect results as this function does not perform
227    ///   value clamping.
228    /// - Does not deal with bad inputs like February with 30 days, does not do any clamping. If you
229    ///   need to sanitize a year, month, day input use
230    ///   [`Dt::clamp_mdhms`](#method.clamp_mdhms) first.
231    /// - The result is the integer JD corresponding to **noon** on the given date.
232    pub const fn ymd_to_jd(yr: i64, mo: u8, day: u8) -> i64 {
233        let m = mo as i16;
234        let d = day as i16;
235
236        let a = (14 - m) / 12;
237        let m = m + 12 * a - 3;
238        let day_mo = d + (153 * m + 2) / 5;
239
240        // Fast path: shifted year `y = yr + 4800 - a` and `365*y + …` fit in i64.
241        // `Y_LIM = i64::MAX/366` leaves headroom for y/4, day_mo, and the −32045 term.
242        const Y_LIM: i64 = i64::MAX / 366;
243        if let Some(y) = yr.checked_add(4800 - a as i64)
244            && y >= -Y_LIM
245            && y <= Y_LIM
246        {
247            let y4 = y >> 2; // floor(y / 4)
248            let y100 = if y >= 0 { y / 100 } else { (y - 99) / 100 };
249            let y400 = y100 >> 2; // floor(y / 400)
250            return day_mo as i64 + 365 * y + y4 - y100 + y400 - 32045;
251        }
252
253        // Wide path: |yr| near i64 edges (or yr + 4800 overflows i64).
254        let y = yr as i128 + 4800 - a as i128;
255        let y4 = y >> 2;
256        let y100 = if y >= 0 { y / 100 } else { (y - 99) / 100 };
257        let y400 = y100 >> 2;
258        let yr_part = 365 * y + y4 - y100 + y400 - 32045;
259        Dt::to_i64(day_mo as i128 + yr_part)
260    }
261
262    /// Computes the Julian Day Number from a Gregorian year and ordinal day-of-year.
263    #[inline]
264    pub const fn ydoy_to_jd(yr: i64, day_of_yr: u16) -> i64 {
265        let jd_jan1 = Self::ymd_to_jd(yr, 1, 1);
266        jd_jan1.saturating_add(day_of_yr as i64 - 1)
267    }
268
269    /// Converts a Julian Day Number to the corresponding weekday number
270    /// (0 = Sunday … 6 = Saturday).
271    #[inline]
272    pub const fn jd_to_wkday(jd: i64) -> u8 {
273        let rem = ((jd as i128) + 1) % 7;
274        let positive = if rem < 0 { rem + 7 } else { rem };
275        positive as u8
276    }
277
278    /// Computes the Julian Day Number from an ISO week date (Monday-based week).
279    pub const fn iso_wk_to_jd(iso_yr: i64, iso_wk: u8, wkday: Weekday) -> i64 {
280        let jan4_jd = Self::ymd_to_jd(iso_yr, 1, 4);
281        let wd_jan4 = Self::jd_to_wkday(jan4_jd);
282
283        let days_to_monday = {
284            let tmp = (wd_jan4 as i64).saturating_add(6);
285            let rem = tmp % 7;
286            if rem < 0 { rem + 7 } else { rem }
287        };
288
289        let monday_wk1 = jan4_jd.saturating_sub(days_to_monday);
290        let monday_requested =
291            monday_wk1.saturating_add(((iso_wk as i64).saturating_sub(1)).saturating_mul(7));
292
293        monday_requested.saturating_add((wkday.wkday_mon_0_based()) as i64)
294    }
295
296    /// Computes the Julian Day Number from a Sunday-based week-of-year (`%U`).
297    pub const fn wk_sun_to_jd(yr: i64, wk: u8, wkday: Weekday) -> i64 {
298        let jan1_jd = Self::ymd_to_jd(yr, 1, 1);
299        let wd_jan1 = Self::jd_to_wkday(jan1_jd);
300
301        let days_to_first_sunday = ((7u8 - wd_jan1) % 7u8) as i64;
302        let first_sunday_jd = jan1_jd.saturating_add(days_to_first_sunday);
303
304        let sunday_of_wk =
305            first_sunday_jd.saturating_add(((wk as i64).saturating_sub(1)).saturating_mul(7));
306
307        sunday_of_wk.saturating_add(wkday.wkday_sun_0_based() as i64)
308    }
309
310    /// Computes the Julian Day Number from a Monday-based week-of-year (`%W`).
311    pub const fn wk_mon_to_jd(yr: i64, wk: u8, wkday: Weekday) -> i64 {
312        let jan1_jd = Self::ymd_to_jd(yr, 1, 1);
313        let wd_jan1 = Self::jd_to_wkday(jan1_jd);
314
315        let days_to_first_monday = (1i64 - wd_jan1 as i64).rem_euclid(7);
316        let first_monday_jd = jan1_jd.saturating_add(days_to_first_monday);
317
318        let monday_of_wk =
319            first_monday_jd.saturating_add(((wk as i64).saturating_sub(1)).saturating_mul(7));
320
321        monday_of_wk.saturating_add((wkday.wkday_mon_0_based()) as i64)
322    }
323
324    /// Returns `true` if the given year is a Gregorian leap year under proleptic rules.
325    #[inline(always)]
326    pub const fn is_leap_yr(yr: i64) -> bool {
327        (yr & 3 == 0) && ((yr & 15 == 0) || (yr % 25 != 0))
328    }
329
330    /// Returns `true` if the supplied values form a valid proleptic Gregorian calendar date.
331    #[inline]
332    pub const fn is_valid_ymd(yr: i64, mo: u8, day: u8) -> bool {
333        if !matches!(mo, 1..=12) || !matches!(day, 1..=31) {
334            return false;
335        }
336        // 0 = Jan, 1 = Feb, ..., 11 = Dec
337        let days = Self::DAYS_IN_GREGORIAN_MONTHS[(mo - 1) as usize];
338        if mo == 2 && Self::is_leap_yr(yr) {
339            day <= days + 1 // 28 → 29
340        } else {
341            day <= days
342        }
343    }
344
345    /// Returns `true` if the given Gregorian year contains an ISO week 53.
346    pub const fn has_iso_wk_53(yr: i64) -> bool {
347        let jan1_jd = Self::ymd_to_jd(yr, 1, 1);
348        let wd_jan1 = Self::jd_to_wkday(jan1_jd);
349        wd_jan1 == 4 || (Self::is_leap_yr(yr) && wd_jan1 == 3)
350    }
351
352    /// Returns the ordinal day of the year (1-based).
353    ///
354    /// January 1 is day `1`; December 31 is day `365` or `366` (in leap years).
355    /// Uses the proleptic Gregorian calendar.
356    pub const fn day_of_yr(&self, ymd: Option<(i64, u8, u8)>) -> u16 {
357        let (yr, mo, day) = if let Some(ymd) = ymd {
358            ymd
359        } else {
360            let g = self.to_ymd();
361            (g.yr, g.mo, g.day)
362        };
363        Self::_day_of_yr(yr, mo, day)
364    }
365
366    pub(crate) const fn _day_of_yr(yr: i64, mo: u8, day: u8) -> u16 {
367        let jd = Self::ymd_to_jd(yr, mo, day);
368        let jd_jan1 = Self::ymd_to_jd(yr, 1, 1);
369
370        let doy = jd.saturating_sub(jd_jan1).saturating_add(1);
371        doy as u16
372    }
373
374    /// Sunday-based week number (`%U` in strftime).
375    ///
376    /// Range: `0..=53`.
377    /// - Week 0 contains the days *before* the first Sunday of the year.
378    /// - Week 1 begins on the first Sunday of the year.
379    ///
380    /// The optional `ymd` and `doy` arguments are performance optimisations
381    /// (same pattern used throughout the file for `day_of_year`, `to_iso_wk_date`, etc.).
382    /// Pass whichever you already have; the function will use the fastest path.
383    pub const fn wk_sun(&self, ymd: Option<(i64, u8, u8)>, doy: Option<u16>) -> u8 {
384        let (yr, _, _) = if let Some(ymd) = ymd {
385            ymd
386        } else {
387            let g = self.to_ymd();
388            (g.yr, g.mo, g.day)
389        };
390        let doy = if let Some(doy) = doy {
391            doy
392        } else {
393            self.day_of_yr(ymd)
394        };
395        Self::_wk_sun(yr, doy)
396    }
397
398    pub(crate) const fn _wk_sun(yr: i64, doy: u16) -> u8 {
399        let jan1_jd = Self::ymd_to_jd(yr, 1, 1);
400        let wd_jan1 = Self::jd_to_wkday(jan1_jd);
401        let days_to_first_sunday = (7u8 - wd_jan1) % 7u8;
402        let first_sunday_doy = days_to_first_sunday as u16 + 1;
403        if doy < first_sunday_doy {
404            0
405        } else {
406            let days_since_first_sunday = doy.saturating_sub(first_sunday_doy);
407            ((days_since_first_sunday / 7) + 1) as u8
408        }
409    }
410
411    /// Monday-based week number (`%W` in strftime).
412    ///
413    /// Range: `0..=53`.
414    /// - Week 0 contains the days *before* the first Monday of the year.
415    /// - Week 1 begins on the first Monday of the year.
416    ///
417    /// The optional `ymd` and `doy` arguments are performance optimisations
418    /// (same pattern as `wk_sun`, `day_of_yr`, `to_iso_wk_date`, etc.).
419    pub const fn wk_mon(&self, ymd: Option<(i64, u8, u8)>, doy: Option<u16>) -> u8 {
420        let (yr, _, _) = if let Some(ymd) = ymd {
421            ymd
422        } else {
423            let g = self.to_ymd();
424            (g.yr, g.mo, g.day)
425        };
426        let doy = if let Some(doy) = doy {
427            doy
428        } else {
429            self.day_of_yr(ymd)
430        };
431        Self::_wk_mon(yr, doy)
432    }
433
434    pub(crate) const fn _wk_mon(yr: i64, doy: u16) -> u8 {
435        let jan1_jd = Self::ymd_to_jd(yr, 1, 1);
436        let wd_jan1 = Self::jd_to_wkday(jan1_jd);
437        let days_to_first_monday = (1i64 - wd_jan1 as i64).rem_euclid(7);
438        let first_monday_doy = days_to_first_monday as u16 + 1;
439        if doy < first_monday_doy {
440            0
441        } else {
442            let days_since_first_monday = doy.saturating_sub(first_monday_doy);
443            ((days_since_first_monday / 7) + 1) as u8
444        }
445    }
446
447    /// Returns the ISO 8601 week date for this `Dt`.
448    ///
449    /// Returns `(iso_year, iso_week, weekday)` where:
450    /// - `iso_year` is the ISO week year (may differ from the Gregorian year near
451    ///   year boundaries),
452    /// - `iso_week` is the week number in the range `1..=53`,
453    /// - `weekday` is a [`Weekday`] value (Monday-based week).
454    ///
455    /// Follows the ISO 8601 standard: weeks start on Monday and week 1 is the
456    /// week containing January 4.
457    ///
458    /// The optional `ymd` argument is a performance optimization. If provided,
459    /// it is used directly; otherwise [`to_ymd`](#method.to_ymd)
460    /// is called internally.
461    pub const fn to_iso_wk_date(&self, ymd: Option<(i64, u8, u8)>) -> (i64, u8, Weekday) {
462        let (yr, mo, day) = if let Some(ymd) = ymd {
463            ymd
464        } else {
465            let g = self.to_ymd();
466            (g.yr, g.mo, g.day)
467        };
468        Self::_to_iso_wk_date(yr, mo, day)
469    }
470
471    pub(crate) const fn _to_iso_wk_date(yr: i64, mo: u8, day: u8) -> (i64, u8, Weekday) {
472        let jd = Self::ymd_to_jd(yr, mo, day);
473        let wd = Self::jd_to_wkday(jd);
474        let wd_iso = if wd == 0 { 7 } else { wd };
475
476        let jan4_jd = Self::ymd_to_jd(yr, 1, 4);
477        let wd_jan4 = Self::jd_to_wkday(jan4_jd);
478        let days_to_monday = {
479            let tmp = (wd_jan4 as i64) + 6;
480            let rem = tmp % 7;
481            if rem < 0 { rem + 7 } else { rem }
482        };
483
484        let monday_wk1 = jan4_jd - days_to_monday;
485
486        let days_since = jd - monday_wk1;
487
488        let wk = if days_since < 0 {
489            0u8
490        } else {
491            ((days_since / 7) + 1) as u8
492        };
493
494        let iso_yr = if wk == 0 {
495            yr - 1
496        } else if wk >= 53 && !Self::has_iso_wk_53(yr) {
497            yr + 1
498        } else {
499            yr
500        };
501
502        let iso_wk = if wk == 0 {
503            if Self::has_iso_wk_53(yr - 1) { 53 } else { 52 }
504        } else if (wk == 53 && !Self::has_iso_wk_53(yr)) || wk > 53 {
505            1
506        } else {
507            wk
508        };
509        let wkday_enum = match Weekday::from_monday_1_based(wd_iso) {
510            Some(w) => w,
511            None => Weekday::Monday,
512        };
513
514        (iso_yr, iso_wk, wkday_enum)
515    }
516
517    /// Number of days in a month under proleptic Gregorian rules.
518    #[inline]
519    pub const fn days_in_month(yr: i64, mo: u8) -> u8 {
520        match mo {
521            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
522            4 | 6 | 9 | 11 => 30,
523            2 => {
524                if Self::is_leap_yr(yr) {
525                    29
526                } else {
527                    28
528                }
529            }
530            _ => 0,
531        }
532    }
533
534    /// Clamps month, day, hour, minutes, and seconds values. Clamps days to what is
535    /// correct for that particular propleptic gregorian month.
536    ///
537    /// For example the year 2000 is a leap year, and February in that year has 29 days
538    /// so the days are clamped to 1-29 in that year, but 1-28 in non-leap years.
539    pub const fn clamp_mdhms(
540        yr: i64,
541        mo: u8,
542        day: u8,
543        hr: u8,
544        min: u8,
545        sec: u8,
546    ) -> (u8, u8, u8, u8, u8) {
547        let mo = Self::clamp_u8(mo, 1, 12);
548        let max_day = Self::days_in_month(yr, mo);
549        let day = Self::clamp_u8(day, 1, max_day);
550        let h = Self::clamp_u8(hr, 0, 23);
551        let m = Self::clamp_u8(min, 0, 59);
552        let s = Self::clamp_u8(sec, 0, 60);
553
554        (mo, day, h, m, s)
555    }
556
557    /// Number of days since 1958-01-01 (proleptic Gregorian) → `(year, month, day)`.
558    /// This is the inverse of [`Dt::ymd_to_days_since_1958`].
559    #[inline]
560    pub const fn days_since_1958_to_ymd(days_since_epoch: i64) -> (i64, u8, u8) {
561        let jd_1958 = Dt::ymd_to_jd(1958, 1, 1);
562        let jd = jd_1958.saturating_add(days_since_epoch);
563        Dt::jd_to_ymd(jd)
564    }
565
566    /// Inverse of [`Dt::days_since_1958_to_ymd`].
567    #[inline]
568    pub const fn ymd_to_days_since_1958(year: i64, month: u8, day: u8) -> i64 {
569        let jd = Dt::ymd_to_jd(year, month, day);
570        let jd_1958 = Dt::ymd_to_jd(1958, 1, 1);
571        jd.saturating_sub(jd_1958)
572    }
573}