Skip to main content

deep_time/dt/
gregorian.rs

1use crate::{
2    ATTOS_PER_SEC, Dt, GregorianTime, SEC_PER_DAYI64, Scale, TSpan, Weekday,
3    leap_seconds::get_leap_seconds,
4};
5
6/// Combined Gregorian date + wall time with subsecond precision.
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
8pub struct YmdHms {
9    pub yr: i64,
10    pub mo: u8,
11    pub day: u8,
12    pub hr: u8,
13    pub min: u8,
14    pub sec: u8,    // 0–60 (60 only during leap seconds)
15    pub attos: u64, // attoseconds (0 ≤ subsec < 10¹⁸)
16}
17
18impl Dt {
19    /// Converts a Unix timestamp (seconds since 1970-01-01 00:00:00 UTC)
20    /// to a proleptic Gregorian date (year, month, day).
21    #[inline]
22    pub const fn unix_sec_to_gregorian_ymd(unix_sec: i64) -> (i64, u8, u8) {
23        let days_since_1970 = unix_sec.div_euclid(SEC_PER_DAYI64);
24        // 1970-01-01 00:00:00 UTC is JD 2440588.0
25        let jdn = days_since_1970.saturating_add(2440588);
26        Self::jdn_to_ymd(jdn)
27    }
28
29    pub const fn to_gregorian_time(&self) -> GregorianTime {
30        // Use the new unified function (replaces the old to_gregorian_ymd + to_hms_subsec calls)
31        let ymdhms = self.to_ymdhms();
32        let unix_attosec = self.to_epoch(Dt::UNIX_EPOCH, Scale::UTC).to_attos();
33
34        let (iso_yr, iso_wk, iso_wkday) =
35            self.to_iso_week_date(Some((ymdhms.yr, ymdhms.mo, ymdhms.day)));
36        let day_of_yr = self.day_of_year(Some((ymdhms.yr, ymdhms.mo, ymdhms.day)));
37        let jdn = Self::ymd_to_jdn(ymdhms.yr, ymdhms.mo, ymdhms.day);
38        let wkday = Self::jdn_to_weekday(jdn);
39        let wk_of_yr_sun = self.wk_sun(Some((ymdhms.yr, ymdhms.mo, ymdhms.day)), Some(day_of_yr));
40        let wk_of_yr_mon = self.wk_mon(Some((ymdhms.yr, ymdhms.mo, ymdhms.day)), Some(day_of_yr));
41
42        GregorianTime {
43            unix_attosec,
44            yr: ymdhms.yr,
45            mo: ymdhms.mo,
46            day: ymdhms.day,
47            hr: ymdhms.hr,
48            min: ymdhms.min,
49            sec: ymdhms.sec,
50            attos: ymdhms.attos,
51            iso_yr,
52            iso_wk,
53            iso_wkday,
54            day_of_yr,
55            wkday,
56            wk_of_yr_sun,
57            wk_of_yr_mon,
58            offset_sec: None,
59            tz: None,
60            tz_abbrev: None,
61        }
62    }
63
64    /// Stripped down version of `Dt::to_gregorian_time`.
65    ///
66    /// Returns the Gregorian date and wall time for this instant.
67    ///
68    /// - For `Scale::UTC`: Uses a direct Unix-timestamp-based path (fast and clean).
69    /// - For all other scales: Uses the standard TT-based JD path.
70    #[inline]
71    pub const fn to_ymdhms(&self) -> YmdHms {
72        // Single call gets us the full civil attos since Unix epoch (POSIX style).
73        // This replaces both to_unix_sec() + the old to_attos_since(UNIX_EPOCH).
74        let canon = self.to_epoch(Dt::UNIX_EPOCH, Scale::UTC);
75
76        let unix_sec = canon.sec;
77        let attos = canon.attos;
78
79        let is_leap_second = get_leap_seconds(&self, false).is_leap_second;
80
81        // For the date we always use the previous second when on a leap second
82        // (so 23:59:60 stays on the correct civil day).
83        let unix_sec_for_date = if is_leap_second {
84            unix_sec - 1
85        } else {
86            unix_sec
87        };
88
89        let (yr, mo, day) = Self::unix_sec_to_gregorian_ymd(unix_sec_for_date);
90
91        // Only the hour/minute/second fields differ for a leap second.
92        let (hr, min, sec) = if is_leap_second {
93            (23, 59, 60)
94        } else {
95            let seconds_since_midnight = unix_sec.rem_euclid(SEC_PER_DAYI64);
96            let hr = (seconds_since_midnight / 3600) as u8;
97            let min = ((seconds_since_midnight % 3600) / 60) as u8;
98            let sec = (seconds_since_midnight % 60) as u8;
99            (hr, min, sec)
100        };
101
102        YmdHms {
103            yr,
104            mo,
105            day,
106            hr,
107            min,
108            sec,
109            attos,
110        }
111    }
112
113    /// Converts a proleptic Gregorian calendar date+time to a Unix timestamp
114    /// (seconds since 1970-01-01 00:00:00 UTC).
115    ///
116    /// This version is correct for the full i64 range, including negative years.
117    pub const fn ymdhms_to_unix_sec(
118        year: i64,
119        month: u8,
120        day: u8,
121        hour: u8,
122        minute: u8,
123        second: u8,
124    ) -> i64 {
125        let jdn = Self::ymd_to_jdn(year, month, day);
126        // 1970-01-01 00:00:00 UTC corresponds to JD 2440588
127        let days_since_1970 = jdn.saturating_sub(2440588);
128        let time_of_day = (hour as i64) * 3600 + (minute as i64) * 60 + (second as i64);
129        days_since_1970
130            .saturating_mul(SEC_PER_DAYI64)
131            .saturating_add(time_of_day)
132    }
133
134    /// Converts a Julian Day Number (JDN) to a proleptic Gregorian calendar date.
135    ///
136    /// Returns `(year, month, day)` where `month` ∈ [1, 12] and `day` ∈ [1, 31]
137    /// (standard 1-based Gregorian values).
138    ///
139    /// This is the inverse of [`Self::ymd_to_jdn`]. Supports the full `i64`
140    /// range, including negative years and year zero.
141    pub const fn jdn_to_ymd(jdn: i64) -> (i64, u8, u8) {
142        // Use i128 internally to avoid overflow on full i64 JDN range
143        let j = jdn as i128;
144
145        // Floor division helper (required for negative JDNs)
146        const fn floor_div(a: i128, b: i128) -> i128 {
147            let q = a / b;
148            let r = a % b;
149            if (r > 0 && b < 0) || (r < 0 && b > 0) {
150                q - 1
151            } else {
152                q
153            }
154        }
155
156        let a = j + 32044;
157        let b = floor_div(4 * a + 3, 146097);
158        let c = a - floor_div(b * 146097, 4);
159        let d = floor_div(4 * c + 3, 1461);
160        let e = c - floor_div(1461 * d, 4);
161        let m = floor_div(5 * e + 2, 153);
162        let day = (e - floor_div(153 * m + 2, 5) + 1) as u8;
163        let month = (m + 3 - 12 * floor_div(m, 10)) as u8;
164        let year = b * 100 + d - 4800 + floor_div(m, 10);
165
166        debug_assert!(day >= 1 && day <= 31);
167        debug_assert!(month >= 1 && month <= 12);
168
169        (year as i64, month, day)
170    }
171
172    /// Computes the Julian Day Number (JDN) for a proleptic Gregorian calendar date at noon UT.
173    ///
174    /// The algorithm matches the standard astronomical convention used throughout the library
175    /// (`ymd_to_jdn(2000, 1, 1) == 2451545`).
176    pub const fn ymd_to_jdn(year: i64, month: u8, day: u8) -> i64 {
177        let a = (14 - month as i64) / 12;
178        let y = year.saturating_add(4800).saturating_sub(a);
179        let m = month as i64 + 12 * a - 3;
180
181        const fn floor_div(a: i64, b: i64) -> i64 {
182            let q = a / b;
183            let r = a % b;
184            if (r > 0 && b < 0) || (r < 0 && b > 0) {
185                q - 1
186            } else {
187                q
188            }
189        }
190
191        let y4 = floor_div(y, 4);
192        let y100 = floor_div(y, 100);
193        let y400 = floor_div(y, 400);
194
195        let result = (day as i64)
196            .saturating_add((153i64 * m + 2) / 5)
197            .saturating_add(365i64 * y)
198            .saturating_add(y4)
199            .saturating_sub(y100)
200            .saturating_add(y400)
201            .saturating_sub(32045);
202
203        result
204    }
205
206    /// Returns `true` if the given year is a Gregorian leap year under proleptic rules.
207    #[inline]
208    pub const fn is_leap_year(year: i64) -> bool {
209        year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
210    }
211
212    /// Creates a `Dt` at the specified civil UTC instant with full
213    /// attosecond precision on the proleptic Gregorian calendar, then converts
214    /// it to the requested [`Scale`].
215    ///
216    /// All input components are clamped to their valid ranges:
217    /// - `mo`   → 0..=12
218    /// - `day`  → 0..=31
219    /// - `hr`   → 0..=23
220    /// - `min`  → 0..=59
221    /// - `sec`  → 0..=60 (permits leap seconds)
222    /// - `attos` → values ≥ 10¹⁸ are carried into the seconds field
223    pub const fn from_ymdhms(
224        yr: i64,
225        mo: u8,
226        day: u8,
227        hr: u8,
228        min: u8,
229        sec: u8,
230        attos: u64,
231        scale: Scale,
232    ) -> Self {
233        let mo = if mo > 12 { 12 } else { mo };
234        let day = if day > 31 { 31 } else { day };
235        let h = if hr > 23 { 23 } else { hr };
236        let m = if min > 59 { 59 } else { min };
237        let s = if sec > 60 { 60 } else { sec };
238
239        let extra_sec = (attos / ATTOS_PER_SEC) as i64;
240        let final_attos = attos % ATTOS_PER_SEC;
241
242        // For an exact leap second (sec==60 with no sub-second carry), compute
243        // the civil Unix timestamp using 23:59:59, create that instant, then
244        // add exactly 1 physical second. This lands on the correct internal TAI
245        // slot (matching LEAP_SECS.tai_sec) while preserving the library's
246        // convention that to_epoch_attos(UTC) for the leap second returns the
247        // "following midnight" civil value. On non-leap days or with carry,
248        // the normal rollover path is used and to_ymdhms_utc will display
249        // correctly because is_leap_second only triggers on exact tai_sec match.
250        let is_exact_leap_second = s == 60 && extra_sec == 0;
251        let s_for_unix = if is_exact_leap_second { 59 } else { s };
252
253        let civil_unix_sec = Self::ymdhms_to_unix_sec(yr, mo, day, h, m, s_for_unix) + extra_sec;
254
255        let tp = Self::from_epoch(
256            TSpan::new(civil_unix_sec, final_attos),
257            Dt::UNIX_EPOCH,
258            scale.to_ut(),
259        );
260        if is_exact_leap_second {
261            tp.add(TSpan::from_sec(1))
262        } else {
263            tp
264        }
265    }
266
267    /// Creates a `Dt` representing **00:00:00 UTC** on the given proleptic
268    /// Gregorian date, converted to the requested [`Scale`].
269    ///
270    /// The date components are interpreted according to POSIX civil time
271    /// (leap seconds are not inserted into the day count).
272    pub const fn from_ymd(yr: i64, mo: u8, day: u8, scale: Scale) -> Self {
273        let unix_sec = Self::ymdhms_to_unix_sec(yr, mo, day, 0, 0, 0);
274
275        Self::from_epoch(TSpan::new(unix_sec, 0), Dt::UNIX_EPOCH, scale.to_ut())
276    }
277
278    /// Computes the Julian Day Number from a Gregorian year and ordinal day-of-year.
279    #[inline]
280    pub const fn ydoy_to_jdn(year: i64, day_of_year: u16) -> i64 {
281        let jdn_jan1 = Self::ymd_to_jdn(year, 1, 1);
282        jdn_jan1.saturating_add(day_of_year as i64 - 1)
283    }
284
285    /// Converts a Julian Day Number to the corresponding weekday number (0 = Sunday … 6 = Saturday).
286    #[inline]
287    pub const fn jdn_to_weekday(jdn: i64) -> u8 {
288        let rem = ((jdn as i128) + 1) % 7;
289        let positive = if rem < 0 { rem + 7 } else { rem };
290        positive as u8
291    }
292
293    /// Computes the Julian Day Number from an ISO week date (Monday-based week).
294    pub const fn ymd_to_jdn_from_iso_week(iso_year: i64, iso_week: u8, weekday: Weekday) -> i64 {
295        let jan4_jdn = Self::ymd_to_jdn(iso_year, 1, 4);
296        let wd_jan4 = Self::jdn_to_weekday(jan4_jdn);
297
298        let days_to_monday = {
299            let tmp = (wd_jan4 as i64).saturating_add(6);
300            let rem = tmp % 7;
301            if rem < 0 { rem + 7 } else { rem }
302        };
303
304        let monday_week1 = jan4_jdn.saturating_sub(days_to_monday);
305
306        let monday_requested =
307            monday_week1.saturating_add(((iso_week as i64).saturating_sub(1)).saturating_mul(7));
308
309        let wd_offset = match weekday {
310            Weekday::Monday => 0,
311            Weekday::Tuesday => 1,
312            Weekday::Wednesday => 2,
313            Weekday::Thursday => 3,
314            Weekday::Friday => 4,
315            Weekday::Saturday => 5,
316            Weekday::Sunday => 6,
317        };
318
319        monday_requested.saturating_add(wd_offset as i64)
320    }
321
322    /// Computes the Julian Day Number from a Sunday-based week-of-year (`%U`).
323    pub const fn ymd_to_jdn_from_week_sun(year: i64, week: u8, weekday: Weekday) -> i64 {
324        let jan1_jdn = Self::ymd_to_jdn(year, 1, 1);
325        let wd_jan1 = Self::jdn_to_weekday(jan1_jdn);
326
327        let days_to_first_sunday = ((7u8 - wd_jan1) % 7u8) as i64;
328        let first_sunday_jdn = jan1_jdn.saturating_add(days_to_first_sunday);
329
330        let sunday_of_week =
331            first_sunday_jdn.saturating_add(((week as i64).saturating_sub(1)).saturating_mul(7));
332
333        let wd_offset = match weekday {
334            Weekday::Sunday => 0,
335            Weekday::Monday => 1,
336            Weekday::Tuesday => 2,
337            Weekday::Wednesday => 3,
338            Weekday::Thursday => 4,
339            Weekday::Friday => 5,
340            Weekday::Saturday => 6,
341        };
342
343        sunday_of_week.saturating_add(wd_offset as i64)
344    }
345
346    /// Computes the Julian Day Number from a Monday-based week-of-year (`%W`).
347    pub const fn ymd_to_jdn_from_week_mon(year: i64, week: u8, weekday: Weekday) -> i64 {
348        let jan1_jdn = Self::ymd_to_jdn(year, 1, 1);
349        let wd_jan1 = Self::jdn_to_weekday(jan1_jdn);
350
351        let days_to_first_monday = (1i64 - wd_jan1 as i64).rem_euclid(7);
352        let first_monday_jdn = jan1_jdn.saturating_add(days_to_first_monday);
353
354        let monday_of_week =
355            first_monday_jdn.saturating_add(((week as i64).saturating_sub(1)).saturating_mul(7));
356
357        let wd_offset = match weekday {
358            Weekday::Monday => 0,
359            Weekday::Tuesday => 1,
360            Weekday::Wednesday => 2,
361            Weekday::Thursday => 3,
362            Weekday::Friday => 4,
363            Weekday::Saturday => 5,
364            Weekday::Sunday => 6,
365        };
366
367        monday_of_week.saturating_add(wd_offset as i64)
368    }
369
370    /// Returns `true` if the supplied values form a valid proleptic Gregorian calendar date.
371    pub const fn is_valid_ymd(year: i64, month: u8, day: u8) -> bool {
372        if month < 1 || month > 12 || day < 1 {
373            return false;
374        }
375        let days = match month {
376            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31u8,
377            4 | 6 | 9 | 11 => 30u8,
378            2 => {
379                if Self::is_leap_year(year) {
380                    29
381                } else {
382                    28
383                }
384            }
385            _ => return false,
386        };
387        day <= days
388    }
389
390    /// Returns `true` if the given Gregorian year contains an ISO week 53.
391    pub const fn has_iso_week_53(year: i64) -> bool {
392        let jan1_jdn = Self::ymd_to_jdn(year, 1, 1);
393        let wd_jan1 = Self::jdn_to_weekday(jan1_jdn);
394        wd_jan1 == 4 || (Self::is_leap_year(year) && wd_jan1 == 3)
395    }
396
397    /// Returns the ordinal day of the year (1-based).
398    ///
399    /// January 1 is day `1`; December 31 is day `365` or `366` (in leap years).
400    /// Uses the proleptic Gregorian calendar.
401    pub const fn day_of_year(&self, ymd: Option<(i64, u8, u8)>) -> u16 {
402        let (year, month, day) = if let Some(ymd) = ymd {
403            ymd
404        } else {
405            let g = self.to_ymdhms();
406            (g.yr, g.mo, g.day)
407        };
408        let jdn = Self::ymd_to_jdn(year, month, day);
409        let jdn_jan1 = Self::ymd_to_jdn(year, 1, 1);
410
411        let doy = jdn.saturating_sub(jdn_jan1).saturating_add(1);
412        doy as u16
413    }
414
415    /// Sunday-based week number (`%U` in strftime).
416    ///
417    /// Range: `0..=53`.
418    /// - Week 0 contains the days *before* the first Sunday of the year.
419    /// - Week 1 begins on the first Sunday of the year.
420    ///
421    /// The optional `ymd` and `doy` arguments are performance optimisations
422    /// (same pattern used throughout the file for `day_of_year`, `to_iso_week_date`, etc.).
423    /// Pass whichever you already have; the function will use the fastest path.
424    pub const fn wk_sun(&self, ymd: Option<(i64, u8, u8)>, doy: Option<u16>) -> u8 {
425        let (year, _, _) = if let Some(ymd) = ymd {
426            ymd
427        } else {
428            let g = self.to_ymdhms();
429            (g.yr, g.mo, g.day)
430        };
431        let doy = if let Some(doy) = doy {
432            doy
433        } else {
434            self.day_of_year(ymd)
435        };
436        let jan1_jdn = Self::ymd_to_jdn(year, 1, 1);
437        let wd_jan1 = Self::jdn_to_weekday(jan1_jdn);
438        let days_to_first_sunday = (7u8 - wd_jan1) % 7u8;
439        let first_sunday_doy = days_to_first_sunday as u16 + 1;
440        if doy < first_sunday_doy {
441            0
442        } else {
443            let days_since_first_sunday = doy.saturating_sub(first_sunday_doy);
444            ((days_since_first_sunday / 7) + 1) as u8
445        }
446    }
447
448    /// Monday-based week number (`%W` in strftime).
449    ///
450    /// Range: `0..=53`.
451    /// - Week 0 contains the days *before* the first Monday of the year.
452    /// - Week 1 begins on the first Monday of the year.
453    ///
454    /// The optional `ymd` and `doy` arguments are performance optimisations
455    /// (same pattern as `wk_sun`, `day_of_year`, `to_iso_week_date`, etc.).
456    pub const fn wk_mon(&self, ymd: Option<(i64, u8, u8)>, doy: Option<u16>) -> u8 {
457        let (year, _, _) = if let Some(ymd) = ymd {
458            ymd
459        } else {
460            let g = self.to_ymdhms();
461            (g.yr, g.mo, g.day)
462        };
463        let doy = if let Some(doy) = doy {
464            doy
465        } else {
466            self.day_of_year(ymd)
467        };
468        let jan1_jdn = Self::ymd_to_jdn(year, 1, 1);
469        let wd_jan1 = Self::jdn_to_weekday(jan1_jdn);
470        let days_to_first_monday = (1i64 - wd_jan1 as i64).rem_euclid(7);
471        let first_monday_doy = days_to_first_monday as u16 + 1;
472        if doy < first_monday_doy {
473            0
474        } else {
475            let days_since_first_monday = doy.saturating_sub(first_monday_doy);
476            ((days_since_first_monday / 7) + 1) as u8
477        }
478    }
479
480    /// Returns the ISO 8601 week date for this `Dt`.
481    ///
482    /// Returns `(iso_year, iso_week, weekday)` where:
483    /// - `iso_year` is the ISO week year (may differ from the Gregorian year near
484    ///   year boundaries),
485    /// - `iso_week` is the week number in the range `1..=53`,
486    /// - `weekday` is a [`Weekday`] value (Monday-based week).
487    ///
488    /// Follows the ISO 8601 standard: weeks start on Monday and week 1 is the
489    /// week containing January 4.
490    ///
491    /// The optional `ymd` argument is a performance optimization. If provided,
492    /// it is used directly; otherwise [`to_gregorian_ymd`](Self::to_gregorian_ymd)
493    /// is called internally.
494    pub const fn to_iso_week_date(&self, ymd: Option<(i64, u8, u8)>) -> (i64, u8, Weekday) {
495        let (year, month, day) = if let Some(ymd) = ymd {
496            ymd
497        } else {
498            let g = self.to_ymdhms();
499            (g.yr, g.mo, g.day)
500        };
501        let jdn = Self::ymd_to_jdn(year, month, day);
502        let wd = Self::jdn_to_weekday(jdn);
503        let wd_iso = if wd == 0 { 7 } else { wd };
504
505        let jan4_jdn = Self::ymd_to_jdn(year, 1, 4);
506        let wd_jan4 = Self::jdn_to_weekday(jan4_jdn);
507        let days_to_monday = {
508            let tmp = (wd_jan4 as i64) + 6;
509            let rem = tmp % 7;
510            if rem < 0 { rem + 7 } else { rem }
511        };
512
513        let monday_week1 = jan4_jdn - days_to_monday;
514
515        let days_since = jdn - monday_week1;
516
517        let week = if days_since < 0 {
518            0u8
519        } else {
520            ((days_since / 7) + 1) as u8
521        };
522
523        let iso_year = if week == 0 {
524            year - 1
525        } else if (week == 53 || week > 53) && !Self::has_iso_week_53(year) {
526            year + 1
527        } else {
528            year
529        };
530
531        let iso_week = if week == 0 {
532            if Self::has_iso_week_53(year - 1) {
533                53
534            } else {
535                52
536            }
537        } else if week == 53 && !Self::has_iso_week_53(year) {
538            1
539        } else if week > 53 {
540            1
541        } else {
542            week
543        };
544
545        let weekday_enum = match wd_iso {
546            1 => Weekday::Monday,
547            2 => Weekday::Tuesday,
548            3 => Weekday::Wednesday,
549            4 => Weekday::Thursday,
550            5 => Weekday::Friday,
551            6 => Weekday::Saturday,
552            _ => Weekday::Sunday,
553        };
554
555        (iso_year, iso_week, weekday_enum)
556    }
557}