Skip to main content

deep_time/dt/
gregorian.rs

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