Skip to main content

deep_time/dt/
gregorian.rs

1use crate::{ATTOS_PER_SEC, Dt, SEC_PER_DAYI64, Scale, Weekday, YmdHms, leap_seconds::leap_sec};
2
3impl Dt {
4    /// Converts a Unix timestamp (seconds since 1970-01-01 00:00:00)
5    /// to a proleptic Gregorian date (year, month, day).
6    pub const fn unix_sec_to_ymd(unix_sec: i64) -> (i64, u8, u8) {
7        let days = unix_sec.div_euclid(86400);
8
9        // Shift so we work relative to 0000-03-01 (makes leap year math cleaner)
10        let z = days + 719468;
11
12        let era = if z >= 0 {
13            z / 146097
14        } else {
15            (z - 146096) / 146097
16        };
17        let doe = z - era * 146097; // [0, 146096]
18        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
19        let y = yoe + era * 400;
20        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
21        let mp = (5 * doy + 2) / 153; // [0, 11]
22        let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
23        let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
24
25        let yr = y + if m <= 2 { 1 } else { 0 };
26
27        (yr, m as u8, d as u8)
28    }
29
30    /// Returns the proleptic Gregorian date and wall-clock time for this instant.
31    ///
32    /// Converts to this [`Dt`]s `target` time scale using the internal current
33    /// `scale` before producing a result.
34    ///
35    /// ## Returns
36    ///
37    /// A [`YmdHms`] containing:
38    ///
39    /// - `yr`, `mo`, `day` — proleptic Gregorian calendar date
40    /// - `hr` (0–23), `min` (0–59), `sec` (0–60)
41    /// - `attos` — fractional second in attoseconds (`0 ≤ attos < 10¹⁸`)
42    /// - `unix_attosec` — total attoseconds since the Unix epoch (`1970-01-01 00:00:00 UTC`)
43    ///   when this instant is expressed in the `new` scale
44    ///
45    /// ## Leap-second handling
46    ///
47    /// If `new` is one of the scales that use leap seconds (`UTC`, `UtcSpice`, or `UtcHist`)
48    /// **and** the instant falls exactly on a leap second, the returned `sec` will be `60`.
49    /// In every other case `sec` is in the range `0..=59`.
50    ///
51    /// The implementation converts internally to TAI before checking leap-second status,
52    /// ensuring correct detection regardless of the input scale.
53    ///
54    /// ## See also
55    ///
56    /// - [`Dt::from_ymd`](../struct.Dt.html#method.from_ymd)
57    ///
58    /// ## Examples
59    ///
60    /// ```rust
61    /// use deep_time::{Dt, Scale};
62    ///
63    /// // `from_ymd` always returns a TAI instant
64    /// let dt = Dt::from_ymd(2024, 6, 15, 12, 30, 45, 0, Scale::UTC);
65    /// let ymd = dt.to_ymd();
66    ///
67    /// assert_eq!(ymd.yr(), 2024);
68    /// assert_eq!(ymd.mo(), 6);
69    /// assert_eq!(ymd.day(), 15);
70    /// assert_eq!(ymd.hr(), 12);
71    /// assert_eq!(ymd.min(), 30);
72    /// assert_eq!(ymd.sec(), 45);
73    /// assert!(ymd.attos() == 0);
74    /// ```
75    pub fn to_ymd(&self) -> YmdHms {
76        let tai = self.to_tai();
77        let from_unix_epoch = self.to_scale_and_diff(Dt::UNIX_EPOCH, false);
78
79        let unix_sec = from_unix_epoch.to_sec64();
80        let frac = from_unix_epoch.to_sec_ufrac();
81        let (yr, mo, day) = Self::unix_sec_to_ymd(unix_sec);
82
83        let seconds_since_midnight = unix_sec.rem_euclid(SEC_PER_DAYI64);
84        let hr = (seconds_since_midnight / 3600) as u8;
85        let min = ((seconds_since_midnight % 3600) / 60) as u8;
86        let mut sec = (seconds_since_midnight % 60) as u8;
87        let is_leap = match tai.leap_sec(false) {
88            Some(i) => i.is_leap_sec,
89            None => false,
90        };
91        if self.target.uses_leap_seconds() && is_leap {
92            sec += 1;
93        }
94
95        YmdHms {
96            unix_attosec: from_unix_epoch.to_attos(),
97            yr,
98            mo,
99            day,
100            hr,
101            min,
102            sec,
103            attos: frac,
104            scale: self.target,
105        }
106    }
107
108    /// Converts a proleptic Gregorian calendar date+time to a Unix timestamp
109    /// (seconds since 1970-01-01 00:00:00).
110    ///
111    /// - Expects **1 based** `mo` and `day`, and **0 based** `hr`, `min`, and `sec`.
112    /// - Does not perform any time scale conversions.
113    /// - Expects clamped values.
114    pub const fn ymd_to_unix_sec(yr: i64, mo: u8, day: u8, hr: u8, min: u8, sec: u8) -> i64 {
115        let jd = Self::ymd_to_jd(yr, mo, day);
116        // 1970-01-01 00:00:00 UTC corresponds to JD 2440588
117        let days_since_1970 = jd.saturating_sub(2440588);
118        let time_of_day = (hr as i64) * 3600 + (min as i64) * 60 + (sec as i64);
119        days_since_1970
120            .saturating_mul(SEC_PER_DAYI64)
121            .saturating_add(time_of_day)
122    }
123
124    /// Converts a Julian Day Number (JD) to a proleptic Gregorian calendar date.
125    ///
126    /// - Returns `(year, month, day)` where `month` ∈ [1, 12] and `day` ∈ [1, 31]
127    ///   (standard 1-based Gregorian values).
128    /// - This is the inverse of [`Dt::ymd_to_jd`](../struct.Dt.html#method.ymd_to_jd).
129    /// - Supports the full `i64` range, including negative years and year zero.
130    pub const fn jd_to_ymd(jd: i64) -> (i64, u8, u8) {
131        let j = jd as i128;
132
133        #[inline]
134        const fn floor_div_pos(a: i128, b: i128) -> i128 {
135            if a >= 0 { a / b } else { (a - (b - 1)) / b }
136        }
137
138        let a = j + 32044;
139        let b = floor_div_pos(4 * a + 3, 146097);
140        let c = a - floor_div_pos(b * 146097, 4);
141        let d = floor_div_pos(4 * c + 3, 1461);
142        let e = c - floor_div_pos(1461 * d, 4);
143        let m = floor_div_pos(5 * e + 2, 153);
144        let day = (e - floor_div_pos(153 * m + 2, 5) + 1) as u8;
145        let mo = (m + 3 - 12 * floor_div_pos(m, 10)) as u8;
146        let yr = b * 100 + d - 4800 + floor_div_pos(m, 10);
147
148        (Dt::i128_to_i64(yr), mo, day)
149    }
150
151    /// Computes the Julian Day Number (JD) for a proleptic Gregorian calendar date at noon UT.
152    /// This is the inverse of [`jd_to_ymd`].
153    ///
154    /// ## Arguments
155    ///
156    /// * `yr`  - Year (any `i64`; proleptic Gregorian)
157    /// * `mo` - Month (**1-based**: `1` = January, `2` = February, ..., `12` = December)
158    /// * `day`   - Day of the month (**1-based**: `1` = first day of the month)
159    ///
160    /// The algorithm matches the standard astronomical convention used throughout the library
161    /// (`ymd_to_jd(2000, 1, 1) == 2451545`).
162    ///
163    /// ## Notes
164    ///
165    /// - This function expects **1 based** `mo` and `day`. Passing `mo = 0` or `day = 0` (or other
166    ///   out-of-range values) will produce incorrect results as this function does not perform
167    ///   value clamping.
168    /// - Does not deal with bad inputs like February with 30 days, does not do any clamping. If you
169    ///   need to sanitize a year, month, day input use
170    ///   [`Dt::clamp_mdhms`](../struct.Dt.html#method.clamp_mdhms) first.
171    /// - The result is the integer JD corresponding to **noon** on the given date.
172    #[inline]
173    pub const fn ymd_to_jd(yr: i64, mo: u8, day: u8) -> i64 {
174        let y = yr as i128;
175        let m = mo as i16;
176        let d = day as i16;
177
178        let a = (14 - m) / 12;
179        let y = y + 4800 - a as i128;
180        let m = m + 12 * a - 3;
181
182        let y4 = y >> 2; // floor(y / 4) — arithmetic shift works for negatives
183
184        // floor(y / 100)
185        let y100 = if y >= 0 { y / 100 } else { (y - 99) / 100 };
186
187        let y400 = y100 >> 2; // floor(y / 400)
188
189        let day_mo = d + (153 * m + 2) / 5;
190        let yr_part = 365 * y + y4 - y100 + y400 - 32045;
191
192        Dt::i128_to_i64(day_mo as i128 + yr_part)
193    }
194
195    /// Creates a **TAI** [`Dt`] from a proleptic gregorian date which is assumed to be on
196    /// the provided time scale.
197    ///
198    /// - Equivalent to [`Dt::from`](../struct.Dt.html#method.from) for the provided date.
199    ///   Except that conversion is performed prior to adding an extra second if the given
200    ///   `sec` is `60`.
201    /// - Returned [`Dt`] will be on the **TAI** time scale.
202    ///
203    /// All input components are clamped to their valid ranges:
204    /// - `mo`   → 1..=12 **1 based**
205    /// - `day`  → 1..=31 **1 based**
206    /// - `hr`   → 0..=23 **0 based**
207    /// - `min`  → 0..=59 **0 based**
208    /// - `sec`  → 0..=60 **0 based** (permits leap seconds)
209    /// - `attos` → 10¹⁸ **0 based** (clamped to under 1 second)
210    pub const fn from_ymd(
211        yr: i64,
212        mo: u8,
213        day: u8,
214        hr: u8,
215        min: u8,
216        sec: u8,
217        attos: u64,
218        scale: Scale,
219    ) -> Dt {
220        let (mo, day, hr, min, sec) = Dt::clamp_mdhms(yr, mo, day, hr, min, sec);
221        let attos = Dt::clamp_u64(attos, 0, ATTOS_PER_SEC - 1);
222
223        let sec_is_60 = sec == 60;
224        let s_for_unix = if sec_is_60 { 59 } else { sec };
225
226        let unix_sec = Dt::ymd_to_unix_sec(yr, mo, day, hr, min, s_for_unix);
227        let unix_attos = Dt::sec_to_attos(unix_sec as i128) + (attos as i128);
228
229        if sec_is_60 && scale.uses_leap_seconds() {
230            let t =
231                Dt::from_diff_and_scale(Dt::new(unix_attos, scale, scale), Dt::UNIX_EPOCH, false);
232            let is_leap = match leap_sec(t.add_sec(1).to_sec64(), false) {
233                Some(i) => i.is_leap_sec,
234                None => false,
235            };
236            if is_leap { t.add_sec(1) } else { t }
237        } else {
238            Dt::from_diff_and_scale(Dt::new(unix_attos, scale, scale), Dt::UNIX_EPOCH, false)
239        }
240    }
241
242    /// Computes the Julian Day Number from a Gregorian year and ordinal day-of-year.
243    #[inline]
244    pub const fn ydoy_to_jd(yr: i64, day_of_yr: u16) -> i64 {
245        let jd_jan1 = Self::ymd_to_jd(yr, 1, 1);
246        jd_jan1.saturating_add(day_of_yr as i64 - 1)
247    }
248
249    /// Converts a Julian Day Number to the corresponding weekday number (0 = Sunday … 6 = Saturday).
250    #[inline]
251    pub const fn jd_to_wkday(jd: i64) -> u8 {
252        let rem = ((jd as i128) + 1) % 7;
253        let positive = if rem < 0 { rem + 7 } else { rem };
254        positive as u8
255    }
256
257    /// Computes the Julian Day Number from an ISO week date (Monday-based week).
258    pub const fn iso_wk_to_jd(iso_yr: i64, iso_wk: u8, wkday: Weekday) -> i64 {
259        let jan4_jd = Self::ymd_to_jd(iso_yr, 1, 4);
260        let wd_jan4 = Self::jd_to_wkday(jan4_jd);
261
262        let days_to_monday = {
263            let tmp = (wd_jan4 as i64).saturating_add(6);
264            let rem = tmp % 7;
265            if rem < 0 { rem + 7 } else { rem }
266        };
267
268        let monday_wk1 = jan4_jd.saturating_sub(days_to_monday);
269        let monday_requested =
270            monday_wk1.saturating_add(((iso_wk as i64).saturating_sub(1)).saturating_mul(7));
271
272        monday_requested.saturating_add((wkday.wkday_mon_0_based()) as i64)
273    }
274
275    /// Computes the Julian Day Number from a Sunday-based week-of-year (`%U`).
276    pub const fn wk_sun_to_jd(yr: i64, wk: u8, wkday: Weekday) -> i64 {
277        let jan1_jd = Self::ymd_to_jd(yr, 1, 1);
278        let wd_jan1 = Self::jd_to_wkday(jan1_jd);
279
280        let days_to_first_sunday = ((7u8 - wd_jan1) % 7u8) as i64;
281        let first_sunday_jd = jan1_jd.saturating_add(days_to_first_sunday);
282
283        let sunday_of_wk =
284            first_sunday_jd.saturating_add(((wk as i64).saturating_sub(1)).saturating_mul(7));
285
286        sunday_of_wk.saturating_add(wkday.wkday_sun_0_based() as i64)
287    }
288
289    /// Computes the Julian Day Number from a Monday-based week-of-year (`%W`).
290    pub const fn wk_mon_to_jd(yr: i64, wk: u8, wkday: Weekday) -> i64 {
291        let jan1_jd = Self::ymd_to_jd(yr, 1, 1);
292        let wd_jan1 = Self::jd_to_wkday(jan1_jd);
293
294        let days_to_first_monday = (1i64 - wd_jan1 as i64).rem_euclid(7);
295        let first_monday_jd = jan1_jd.saturating_add(days_to_first_monday);
296
297        let monday_of_wk =
298            first_monday_jd.saturating_add(((wk as i64).saturating_sub(1)).saturating_mul(7));
299
300        monday_of_wk.saturating_add((wkday.wkday_mon_0_based()) as i64)
301    }
302
303    /// Returns `true` if the given year is a Gregorian leap year under proleptic rules.
304    #[inline(always)]
305    pub const fn is_leap_yr(yr: i64) -> bool {
306        (yr & 3 == 0) && ((yr & 15 == 0) || (yr % 25 != 0))
307    }
308
309    const DAYS: [u8; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
310    /// Returns `true` if the supplied values form a valid proleptic Gregorian calendar date.
311    #[inline]
312    pub const fn is_valid_ymd(yr: i64, mo: u8, day: u8) -> bool {
313        if mo < 1 || mo > 12 || day < 1 {
314            return false;
315        }
316        // 0 = Jan, 1 = Feb, ..., 11 = Dec
317        let days = Self::DAYS[(mo - 1) as usize];
318        if mo == 2 && Self::is_leap_yr(yr) {
319            day <= days + 1 // 28 → 29
320        } else {
321            day <= days
322        }
323    }
324
325    /// Returns `true` if the given Gregorian year contains an ISO week 53.
326    pub const fn has_iso_wk_53(yr: i64) -> bool {
327        let jan1_jd = Self::ymd_to_jd(yr, 1, 1);
328        let wd_jan1 = Self::jd_to_wkday(jan1_jd);
329        wd_jan1 == 4 || (Self::is_leap_yr(yr) && wd_jan1 == 3)
330    }
331
332    /// Returns the ordinal day of the year (1-based).
333    ///
334    /// January 1 is day `1`; December 31 is day `365` or `366` (in leap years).
335    /// Uses the proleptic Gregorian calendar.
336    pub fn day_of_yr(&self, ymd: Option<(i64, u8, u8)>) -> u16 {
337        let (yr, mo, day) = if let Some(ymd) = ymd {
338            ymd
339        } else {
340            let g = self.to_ymd();
341            (g.yr, g.mo, g.day)
342        };
343        Self::_day_of_yr(yr, mo, day)
344    }
345
346    pub(crate) fn _day_of_yr(yr: i64, mo: u8, day: u8) -> u16 {
347        let jd = Self::ymd_to_jd(yr, mo, day);
348        let jd_jan1 = Self::ymd_to_jd(yr, 1, 1);
349
350        let doy = jd.saturating_sub(jd_jan1).saturating_add(1);
351        doy as u16
352    }
353
354    /// Sunday-based week number (`%U` in strftime).
355    ///
356    /// Range: `0..=53`.
357    /// - Week 0 contains the days *before* the first Sunday of the year.
358    /// - Week 1 begins on the first Sunday of the year.
359    ///
360    /// The optional `ymd` and `doy` arguments are performance optimisations
361    /// (same pattern used throughout the file for `day_of_year`, `to_iso_wk_date`, etc.).
362    /// Pass whichever you already have; the function will use the fastest path.
363    pub fn wk_sun(&self, ymd: Option<(i64, u8, u8)>, doy: Option<u16>) -> u8 {
364        let (yr, _, _) = if let Some(ymd) = ymd {
365            ymd
366        } else {
367            let g = self.to_ymd();
368            (g.yr, g.mo, g.day)
369        };
370        let doy = if let Some(doy) = doy {
371            doy
372        } else {
373            self.day_of_yr(ymd)
374        };
375        Self::_wk_sun(yr, doy)
376    }
377
378    pub(crate) fn _wk_sun(yr: i64, doy: u16) -> u8 {
379        let jan1_jd = Self::ymd_to_jd(yr, 1, 1);
380        let wd_jan1 = Self::jd_to_wkday(jan1_jd);
381        let days_to_first_sunday = (7u8 - wd_jan1) % 7u8;
382        let first_sunday_doy = days_to_first_sunday as u16 + 1;
383        if doy < first_sunday_doy {
384            0
385        } else {
386            let days_since_first_sunday = doy.saturating_sub(first_sunday_doy);
387            ((days_since_first_sunday / 7) + 1) as u8
388        }
389    }
390
391    /// Monday-based week number (`%W` in strftime).
392    ///
393    /// Range: `0..=53`.
394    /// - Week 0 contains the days *before* the first Monday of the year.
395    /// - Week 1 begins on the first Monday of the year.
396    ///
397    /// The optional `ymd` and `doy` arguments are performance optimisations
398    /// (same pattern as `wk_sun`, `day_of_yr`, `to_iso_wk_date`, etc.).
399    pub fn wk_mon(&self, ymd: Option<(i64, u8, u8)>, doy: Option<u16>) -> u8 {
400        let (yr, _, _) = if let Some(ymd) = ymd {
401            ymd
402        } else {
403            let g = self.to_ymd();
404            (g.yr, g.mo, g.day)
405        };
406        let doy = if let Some(doy) = doy {
407            doy
408        } else {
409            self.day_of_yr(ymd)
410        };
411        Self::_wk_mon(yr, doy)
412    }
413
414    pub(crate) fn _wk_mon(yr: i64, doy: u16) -> u8 {
415        let jan1_jd = Self::ymd_to_jd(yr, 1, 1);
416        let wd_jan1 = Self::jd_to_wkday(jan1_jd);
417        let days_to_first_monday = (1i64 - wd_jan1 as i64).rem_euclid(7);
418        let first_monday_doy = days_to_first_monday as u16 + 1;
419        if doy < first_monday_doy {
420            0
421        } else {
422            let days_since_first_monday = doy.saturating_sub(first_monday_doy);
423            ((days_since_first_monday / 7) + 1) as u8
424        }
425    }
426
427    /// Returns the ISO 8601 week date for this `Dt`.
428    ///
429    /// Returns `(iso_year, iso_week, weekday)` where:
430    /// - `iso_year` is the ISO week year (may differ from the Gregorian year near
431    ///   year boundaries),
432    /// - `iso_week` is the week number in the range `1..=53`,
433    /// - `weekday` is a [`Weekday`] value (Monday-based week).
434    ///
435    /// Follows the ISO 8601 standard: weeks start on Monday and week 1 is the
436    /// week containing January 4.
437    ///
438    /// The optional `ymd` argument is a performance optimization. If provided,
439    /// it is used directly; otherwise [`to_gregorian_ymd`](Self::to_gregorian_ymd)
440    /// is called internally.
441    pub fn to_iso_wk_date(&self, ymd: Option<(i64, u8, u8)>) -> (i64, u8, Weekday) {
442        let (yr, mo, day) = if let Some(ymd) = ymd {
443            ymd
444        } else {
445            let g = self.to_ymd();
446            (g.yr, g.mo, g.day)
447        };
448        Self::_to_iso_wk_date(yr, mo, day)
449    }
450
451    pub(crate) fn _to_iso_wk_date(yr: i64, mo: u8, day: u8) -> (i64, u8, Weekday) {
452        let jd = Self::ymd_to_jd(yr, mo, day);
453        let wd = Self::jd_to_wkday(jd);
454        let wd_iso = if wd == 0 { 7 } else { wd };
455
456        let jan4_jd = Self::ymd_to_jd(yr, 1, 4);
457        let wd_jan4 = Self::jd_to_wkday(jan4_jd);
458        let days_to_monday = {
459            let tmp = (wd_jan4 as i64) + 6;
460            let rem = tmp % 7;
461            if rem < 0 { rem + 7 } else { rem }
462        };
463
464        let monday_wk1 = jan4_jd - days_to_monday;
465
466        let days_since = jd - monday_wk1;
467
468        let wk = if days_since < 0 {
469            0u8
470        } else {
471            ((days_since / 7) + 1) as u8
472        };
473
474        let iso_yr = if wk == 0 {
475            yr - 1
476        } else if wk >= 53 && !Self::has_iso_wk_53(yr) {
477            yr + 1
478        } else {
479            yr
480        };
481
482        let iso_wk = if wk == 0 {
483            if Self::has_iso_wk_53(yr - 1) { 53 } else { 52 }
484        } else if (wk == 53 && !Self::has_iso_wk_53(yr)) || wk > 53 {
485            1
486        } else {
487            wk
488        };
489        let wkday_enum = match Weekday::from_monday_1_based(wd_iso) {
490            Some(w) => w,
491            None => Weekday::Monday,
492        };
493
494        (iso_yr, iso_wk, wkday_enum)
495    }
496
497    /// Number of days in a month under proleptic Gregorian rules.
498    #[inline]
499    pub const fn days_in_month(yr: i64, mo: u8) -> u8 {
500        match mo {
501            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
502            4 | 6 | 9 | 11 => 30,
503            2 => {
504                if Self::is_leap_yr(yr) {
505                    29
506                } else {
507                    28
508                }
509            }
510            _ => 0,
511        }
512    }
513
514    /// Clamps month, day, hour, minutes, and seconds values. Clamps days to what is
515    /// correct for that particular propleptic gregorian month.
516    ///
517    /// For example the year 2000 is a leap year, and February in that year has 29 days
518    /// so the days are clamped to 1-29 in that year, but 1-28 in non-leap years.
519    pub const fn clamp_mdhms(
520        yr: i64,
521        mo: u8,
522        day: u8,
523        hr: u8,
524        min: u8,
525        sec: u8,
526    ) -> (u8, u8, u8, u8, u8) {
527        let mo = Self::clamp_u8(mo, 1, 12);
528        let max_day = Self::days_in_month(yr, mo);
529        let day = Self::clamp_u8(day, 1, max_day);
530        let h = Self::clamp_u8(hr, 0, 23);
531        let m = Self::clamp_u8(min, 0, 59);
532        let s = Self::clamp_u8(sec, 0, 60);
533
534        (mo, day, h, m, s)
535    }
536}