polars_time/windows/
duration.rs

1use std::cmp::Ordering;
2use std::fmt::{Display, Formatter};
3use std::ops::{Mul, Neg};
4
5#[cfg(feature = "timezones")]
6use arrow::legacy::kernels::{Ambiguous, NonExistent};
7use arrow::legacy::time_zone::Tz;
8use arrow::temporal_conversions::{
9    MICROSECONDS, MILLISECONDS, NANOSECONDS, timestamp_ms_to_datetime, timestamp_ns_to_datetime,
10    timestamp_us_to_datetime,
11};
12use chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime, Timelike};
13use polars_core::datatypes::DataType;
14use polars_core::prelude::{
15    PolarsResult, TimeZone, datetime_to_timestamp_ms, datetime_to_timestamp_ns,
16    datetime_to_timestamp_us, polars_bail,
17};
18use polars_error::polars_ensure;
19#[cfg(feature = "serde")]
20use serde::{Deserialize, Serialize};
21
22use super::calendar::{
23    NS_DAY, NS_HOUR, NS_MICROSECOND, NS_MILLISECOND, NS_MINUTE, NS_SECOND, NS_WEEK,
24};
25#[cfg(feature = "timezones")]
26use crate::utils::{localize_datetime_opt, try_localize_datetime, unlocalize_datetime};
27use crate::windows::calendar::{DAYS_PER_MONTH, is_leap_year};
28
29#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
30#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
31#[cfg_attr(feature = "dsl-schema", derive(schemars::JsonSchema))]
32pub struct Duration {
33    // the number of months for the duration
34    months: i64,
35    // the number of weeks for the duration
36    weeks: i64,
37    // the number of days for the duration
38    days: i64,
39    // the number of nanoseconds for the duration
40    nsecs: i64,
41    // indicates if the duration is negative
42    pub(crate) negative: bool,
43    // indicates if an integer string was passed. e.g. "2i"
44    pub parsed_int: bool,
45}
46
47impl PartialOrd<Self> for Duration {
48    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
49        Some(self.cmp(other))
50    }
51}
52
53impl Ord for Duration {
54    fn cmp(&self, other: &Self) -> Ordering {
55        self.duration_ns().cmp(&other.duration_ns())
56    }
57}
58
59impl Neg for Duration {
60    type Output = Self;
61
62    fn neg(self) -> Self::Output {
63        Self {
64            months: self.months,
65            weeks: self.weeks,
66            days: self.days,
67            nsecs: self.nsecs,
68            negative: !self.negative,
69            parsed_int: self.parsed_int,
70        }
71    }
72}
73
74impl Display for Duration {
75    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
76        if self.is_zero() {
77            return write!(f, "0s");
78        }
79        if self.negative {
80            write!(f, "-")?
81        }
82        if self.months > 0 {
83            write!(f, "{}mo", self.months)?
84        }
85        if self.weeks > 0 {
86            write!(f, "{}w", self.weeks)?
87        }
88        if self.days > 0 {
89            write!(f, "{}d", self.days)?
90        }
91        if self.nsecs > 0 {
92            let secs = self.nsecs / NANOSECONDS;
93            if secs * NANOSECONDS == self.nsecs {
94                write!(f, "{secs}s")?
95            } else {
96                let us = self.nsecs / 1_000;
97                if us * 1_000 == self.nsecs {
98                    write!(f, "{us}us")?
99                } else {
100                    write!(f, "{}ns", self.nsecs)?
101                }
102            }
103        }
104        Ok(())
105    }
106}
107
108impl Duration {
109    /// Create a new integer size `Duration`
110    pub fn new(fixed_slots: i64) -> Self {
111        Duration {
112            months: 0,
113            weeks: 0,
114            days: 0,
115            nsecs: fixed_slots.abs(),
116            negative: fixed_slots < 0,
117            parsed_int: true,
118        }
119    }
120
121    /// Parse a string into a `Duration`
122    ///
123    /// Strings are composed of a sequence of number-unit pairs, such as `5d` (5 days). A string may begin with a minus
124    /// sign, in which case it is interpreted as a negative duration. Some examples:
125    ///
126    /// * `"1y"`: 1 year
127    /// * `"-1w2d"`: negative 1 week, 2 days (i.e. -9 days)
128    /// * `"3d12h4m25s"`: 3 days, 12 hours, 4 minutes, and 25 seconds
129    ///
130    /// Aside from a leading minus sign, strings may not contain any characters other than numbers and letters
131    /// (including whitespace).
132    ///
133    /// The available units, in ascending order of magnitude, are as follows:
134    ///
135    /// * `ns`: nanosecond
136    /// * `us`: microsecond
137    /// * `ms`: millisecond
138    /// * `s`:  second
139    /// * `m`:  minute
140    /// * `h`:  hour
141    /// * `d`:  day
142    /// * `w`:  week
143    /// * `mo`: calendar month
144    /// * `q`: calendar quarter
145    /// * `y`:  calendar year
146    /// * `i`:  index value (only for {Int32, Int64} dtypes)
147    ///
148    /// By "calendar day", we mean the corresponding time on the next
149    /// day (which may not be 24 hours, depending on daylight savings).
150    /// Similarly for "calendar week", "calendar month", "calendar quarter",
151    /// and "calendar year".
152    ///
153    /// # Panics
154    /// If the given str is invalid for any reason.
155    pub fn parse(duration: &str) -> Self {
156        Self::try_parse(duration).unwrap()
157    }
158
159    #[doc(hidden)]
160    /// Parse SQL-style "interval" string to Duration. Handles verbose
161    /// units (such as 'year', 'minutes', etc.) and whitespace, as
162    /// well as being case-insensitive.
163    pub fn parse_interval(interval: &str) -> Self {
164        Self::try_parse_interval(interval).unwrap()
165    }
166
167    pub fn try_parse(duration: &str) -> PolarsResult<Self> {
168        Self::_parse(duration, false)
169    }
170
171    pub fn try_parse_interval(interval: &str) -> PolarsResult<Self> {
172        Self::_parse(&interval.to_ascii_lowercase(), true)
173    }
174
175    fn _parse(s: &str, as_interval: bool) -> PolarsResult<Self> {
176        let s = if as_interval { s.trim_start() } else { s };
177        let parse_type = if as_interval { "interval" } else { "duration" };
178
179        // can work on raw bytes (much faster), as valid interval/duration strings are all ASCII
180        let original_string = s;
181        let s = s.as_bytes();
182        let mut pos = 0;
183
184        // check for an initial '+'/'-' char
185        let (leading_minus, leading_plus) = match s.first() {
186            Some(&b'-') => (true, false),
187            Some(&b'+') => (false, true),
188            _ => (false, false),
189        };
190
191        // if leading '+'/'-' found, consume it
192        if leading_minus || leading_plus {
193            pos += 1;
194        }
195
196        // permissive whitespace for intervals
197        if as_interval {
198            while pos < s.len() && s[pos] == b' ' {
199                pos += 1;
200            }
201        }
202
203        // we only allow/expect a single leading '+' or '-' char
204        macro_rules! error_on_second_plus_minus {
205            ($ch:expr) => {{
206                let previously_seen = if $ch == b'-' { leading_minus } else { leading_plus };
207                if previously_seen {
208                    polars_bail!(InvalidOperation: "{} string can only have a single '{}' sign", parse_type, $ch as char);
209                }
210                let sign = if $ch == b'-' { "minus" } else { "plus" };
211                if as_interval {
212                    polars_bail!(InvalidOperation: "{} signs are not currently supported in interval strings", sign);
213                } else {
214                    polars_bail!(InvalidOperation: "only a single {} sign is allowed, at the front of the string", sign);
215                }
216            }};
217        }
218
219        // walk the byte-string, identifying number-unit pairs
220        let mut parsed_int = false;
221        let mut months = 0;
222        let mut weeks = 0;
223        let mut days = 0;
224        let mut nsecs = 0;
225
226        while pos < s.len() {
227            let ch = s[pos];
228            if !ch.is_ascii_digit() {
229                if ch == b'-' || ch == b'+' {
230                    error_on_second_plus_minus!(ch);
231                }
232                polars_bail!(InvalidOperation:
233                    "expected leading integer in the {} string, found '{}'",
234                    parse_type, ch as char
235                );
236            }
237
238            // get integer value from the raw bytes
239            let mut n = 0i64;
240            while pos < s.len() && s[pos].is_ascii_digit() {
241                n = n * 10 + (s[pos] - b'0') as i64;
242                pos += 1;
243            }
244            if pos >= s.len() {
245                polars_bail!(InvalidOperation:
246                    "expected a valid unit to follow integer in the {} string '{}'",
247                    parse_type, original_string
248                );
249            }
250
251            // skip leading whitespace/commas before unit (for intervals)
252            if as_interval {
253                while pos < s.len() && (s[pos] == b' ' || s[pos] == b',') {
254                    pos += 1;
255                }
256            }
257
258            // parse the unit associated with the given integer value
259            let unit_start = pos;
260            while pos < s.len() && s[pos].is_ascii_alphabetic() {
261                pos += 1;
262            }
263            let unit_end = pos;
264            if unit_start == unit_end {
265                polars_bail!(InvalidOperation:
266                    "expected a valid unit to follow integer in the {} string '{}'",
267                    parse_type, original_string
268                );
269            }
270
271            // only valid location for '+'/'-' chars is at the start
272            if pos < s.len() && (s[pos] == b'-' || s[pos] == b'+') {
273                error_on_second_plus_minus!(s[pos]);
274            }
275
276            // skip any whitespace/comma that follows an interval unit
277            if as_interval {
278                while pos < s.len() && (s[pos] == b' ' || s[pos] == b',') {
279                    pos += 1;
280                }
281            }
282
283            let unit = &s[unit_start..unit_end];
284            match unit {
285                // matches that are allowed for both duration and interval
286                b"ns" => nsecs += n,
287                b"us" => nsecs += n * NS_MICROSECOND,
288                b"ms" => nsecs += n * NS_MILLISECOND,
289                b"s" => nsecs += n * NS_SECOND,
290                b"m" => nsecs += n * NS_MINUTE,
291                b"h" => nsecs += n * NS_HOUR,
292                b"d" => days += n,
293                b"w" => weeks += n,
294                b"mo" => months += n,
295                b"q" => months += n * 3,
296                b"y" => months += n * 12,
297                b"i" => {
298                    nsecs += n;
299                    parsed_int = true;
300                },
301                // interval-only (verbose/sql) matches
302                _ if as_interval => match unit {
303                    b"nanosecond" | b"nanoseconds" => nsecs += n,
304                    b"microsecond" | b"microseconds" => nsecs += n * NS_MICROSECOND,
305                    b"millisecond" | b"milliseconds" => nsecs += n * NS_MILLISECOND,
306                    b"sec" | b"secs" | b"second" | b"seconds" => nsecs += n * NS_SECOND,
307                    b"min" | b"mins" | b"minute" | b"minutes" => nsecs += n * NS_MINUTE,
308                    b"hour" | b"hours" => nsecs += n * NS_HOUR,
309                    b"day" | b"days" => days += n,
310                    b"week" | b"weeks" => weeks += n,
311                    b"mon" | b"mons" | b"month" | b"months" => months += n,
312                    b"quarter" | b"quarters" => months += n * 3,
313                    b"year" | b"years" => months += n * 12,
314                    _ => {
315                        let unit_str = std::str::from_utf8(unit).unwrap_or("<invalid>");
316                        let valid_units = "'year', 'month', 'quarter', 'week', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond'";
317                        polars_bail!(InvalidOperation: "unit: '{}' not supported; available units include: {} (and their plurals)", unit_str, valid_units);
318                    },
319                },
320                _ => {
321                    let unit_str = std::str::from_utf8(unit).unwrap_or("<invalid>");
322                    polars_bail!(InvalidOperation: "unit: '{}' not supported; available units are: 'y', 'mo', 'q', 'w', 'd', 'h', 'm', 's', 'ms', 'us', 'ns'", unit_str);
323                },
324            }
325        }
326
327        Ok(Duration {
328            months: months.abs(),
329            weeks: weeks.abs(),
330            days: days.abs(),
331            nsecs: nsecs.abs(),
332            negative: leading_minus,
333            parsed_int,
334        })
335    }
336
337    fn to_positive(v: i64) -> (bool, i64) {
338        if v < 0 { (true, -v) } else { (false, v) }
339    }
340
341    /// Normalize the duration within the interval.
342    /// It will ensure that the output duration is the smallest positive
343    /// duration that is the equivalent of the current duration.
344    #[allow(dead_code)]
345    pub(crate) fn normalize(&self, interval: &Duration) -> Self {
346        if self.months_only() && interval.months_only() {
347            let mut months = self.months() % interval.months();
348
349            match (self.negative, interval.negative) {
350                (true, true) | (true, false) => months = -months + interval.months(),
351                _ => {},
352            }
353            Duration::from_months(months)
354        } else if self.weeks_only() && interval.weeks_only() {
355            let mut weeks = self.weeks() % interval.weeks();
356
357            match (self.negative, interval.negative) {
358                (true, true) | (true, false) => weeks = -weeks + interval.weeks(),
359                _ => {},
360            }
361            Duration::from_weeks(weeks)
362        } else if self.days_only() && interval.days_only() {
363            let mut days = self.days() % interval.days();
364
365            match (self.negative, interval.negative) {
366                (true, true) | (true, false) => days = -days + interval.days(),
367                _ => {},
368            }
369            Duration::from_days(days)
370        } else {
371            let mut offset = self.duration_ns();
372            if offset == 0 {
373                return *self;
374            }
375            let every = interval.duration_ns();
376
377            if offset < 0 {
378                offset += every * ((offset / -every) + 1)
379            } else {
380                offset -= every * (offset / every)
381            }
382            Duration::from_nsecs(offset)
383        }
384    }
385
386    /// Creates a [`Duration`] that represents a fixed number of nanoseconds.
387    pub(crate) fn from_nsecs(v: i64) -> Self {
388        let (negative, nsecs) = Self::to_positive(v);
389        Self {
390            months: 0,
391            weeks: 0,
392            days: 0,
393            nsecs,
394            negative,
395            parsed_int: false,
396        }
397    }
398
399    /// Creates a [`Duration`] that represents a fixed number of months.
400    pub(crate) fn from_months(v: i64) -> Self {
401        let (negative, months) = Self::to_positive(v);
402        Self {
403            months,
404            weeks: 0,
405            days: 0,
406            nsecs: 0,
407            negative,
408            parsed_int: false,
409        }
410    }
411
412    /// Creates a [`Duration`] that represents a fixed number of weeks.
413    pub(crate) fn from_weeks(v: i64) -> Self {
414        let (negative, weeks) = Self::to_positive(v);
415        Self {
416            months: 0,
417            weeks,
418            days: 0,
419            nsecs: 0,
420            negative,
421            parsed_int: false,
422        }
423    }
424
425    /// Creates a [`Duration`] that represents a fixed number of days.
426    pub(crate) fn from_days(v: i64) -> Self {
427        let (negative, days) = Self::to_positive(v);
428        Self {
429            months: 0,
430            weeks: 0,
431            days,
432            nsecs: 0,
433            negative,
434            parsed_int: false,
435        }
436    }
437
438    /// `true` if zero duration.
439    pub fn is_zero(&self) -> bool {
440        self.months == 0 && self.weeks == 0 && self.days == 0 && self.nsecs == 0
441    }
442
443    pub fn months_only(&self) -> bool {
444        self.months != 0 && self.weeks == 0 && self.days == 0 && self.nsecs == 0
445    }
446
447    pub fn months(&self) -> i64 {
448        self.months
449    }
450
451    pub fn weeks_only(&self) -> bool {
452        self.months == 0 && self.weeks != 0 && self.days == 0 && self.nsecs == 0
453    }
454
455    pub fn weeks(&self) -> i64 {
456        self.weeks
457    }
458
459    pub fn days_only(&self) -> bool {
460        self.months == 0 && self.weeks == 0 && self.days != 0 && self.nsecs == 0
461    }
462
463    pub fn days(&self) -> i64 {
464        self.days
465    }
466
467    /// Returns whether the duration consists of full days.
468    ///
469    /// Note that 24 hours is not considered a full day due to possible
470    /// daylight savings time transitions.
471    pub fn is_full_days(&self) -> bool {
472        self.nsecs == 0
473    }
474
475    pub fn is_constant_duration(&self, time_zone: Option<&TimeZone>) -> bool {
476        if time_zone.is_none() || time_zone == Some(&TimeZone::UTC) {
477            self.months == 0
478        } else {
479            // For non-native, non-UTC time zones, 1 calendar day is not
480            // necessarily 24 hours due to daylight savings time.
481            self.months == 0 && self.weeks == 0 && self.days == 0
482        }
483    }
484
485    /// Returns the nanoseconds from the `Duration` without the weeks or months part.
486    pub fn nanoseconds(&self) -> i64 {
487        self.nsecs
488    }
489
490    /// Returns whether duration is negative.
491    pub fn negative(&self) -> bool {
492        self.negative
493    }
494
495    /// Estimated duration of the window duration. Not a very good one if not a constant duration.
496    #[doc(hidden)]
497    pub const fn duration_ns(&self) -> i64 {
498        self.months * 28 * 24 * 3600 * NANOSECONDS
499            + self.weeks * NS_WEEK
500            + self.days * NS_DAY
501            + self.nsecs
502    }
503
504    #[doc(hidden)]
505    pub const fn duration_us(&self) -> i64 {
506        self.months * 28 * 24 * 3600 * MICROSECONDS
507            + (self.weeks * NS_WEEK / 1000 + self.nsecs / 1000 + self.days * NS_DAY / 1000)
508    }
509
510    #[doc(hidden)]
511    pub const fn duration_ms(&self) -> i64 {
512        self.months * 28 * 24 * 3600 * MILLISECONDS
513            + (self.weeks * NS_WEEK / 1_000_000
514                + self.nsecs / 1_000_000
515                + self.days * NS_DAY / 1_000_000)
516    }
517
518    #[doc(hidden)]
519    fn add_month(ts: NaiveDateTime, n_months: i64, negative: bool) -> NaiveDateTime {
520        let mut months = n_months;
521        if negative {
522            months = -months;
523        }
524
525        // Retrieve the current date and increment the values
526        // based on the number of months
527        let mut year = ts.year();
528        let mut month = ts.month() as i32;
529        let mut day = ts.day();
530        year += (months / 12) as i32;
531        month += (months % 12) as i32;
532
533        // if the month overflowed or underflowed, adjust the year
534        // accordingly. Because we add the modulo for the months
535        // the year will only adjust by one
536        if month > 12 {
537            year += 1;
538            month -= 12;
539        } else if month <= 0 {
540            year -= 1;
541            month += 12;
542        }
543
544        // Normalize the day if we are past the end of the month.
545        let last_day_of_month =
546            DAYS_PER_MONTH[is_leap_year(year) as usize][(month - 1) as usize] as u32;
547
548        if day > last_day_of_month {
549            day = last_day_of_month
550        }
551
552        // Retrieve the original time and construct a data
553        // with the new year, month and day
554        let hour = ts.hour();
555        let minute = ts.minute();
556        let sec = ts.second();
557        let nsec = ts.nanosecond();
558        new_datetime(year, month as u32, day, hour, minute, sec, nsec).expect(
559            "Expected valid datetime, please open an issue at https://github.com/pola-rs/polars/issues"
560        )
561    }
562
563    /// Localize result to given time zone, respecting DST fold of original datetime.
564    /// For example, 2022-11-06 01:30:00 CST truncated by 1 hour becomes 2022-11-06 01:00:00 CST,
565    /// whereas 2022-11-06 01:30:00 CDT truncated by 1 hour becomes 2022-11-06 01:00:00 CDT.
566    ///
567    /// * `original_dt_local` - original datetime, without time zone.
568    ///   E.g. if the original datetime was 2022-11-06 01:30:00 CST, then this would
569    ///   be 2022-11-06 01:30:00.
570    /// * `original_dt_utc` - original datetime converted to UTC. E.g. if the
571    ///   original datetime was 2022-11-06 01:30:00 CST, then this would
572    ///   be 2022-11-06 07:30:00.
573    /// * `result_dt_local` - result, without time zone.
574    #[cfg(feature = "timezones")]
575    fn localize_result(
576        &self,
577        original_dt_local: NaiveDateTime,
578        original_dt_utc: NaiveDateTime,
579        result_dt_local: NaiveDateTime,
580        tz: &Tz,
581    ) -> PolarsResult<NaiveDateTime> {
582        match localize_datetime_opt(result_dt_local, tz, Ambiguous::Raise) {
583            Some(dt) => Ok(dt.expect("we didn't use Ambiguous::Null")),
584            None => {
585                if try_localize_datetime(
586                    original_dt_local,
587                    tz,
588                    Ambiguous::Earliest,
589                    NonExistent::Raise,
590                )?
591                .expect("we didn't use Ambiguous::Null or NonExistent::Null")
592                    == original_dt_utc
593                {
594                    Ok(try_localize_datetime(
595                        result_dt_local,
596                        tz,
597                        Ambiguous::Earliest,
598                        NonExistent::Raise,
599                    )?
600                    .expect("we didn't use Ambiguous::Null or NonExistent::Null"))
601                } else if try_localize_datetime(
602                    original_dt_local,
603                    tz,
604                    Ambiguous::Latest,
605                    NonExistent::Raise,
606                )?
607                .expect("we didn't use Ambiguous::Null or NonExistent::Null")
608                    == original_dt_utc
609                {
610                    Ok(try_localize_datetime(
611                        result_dt_local,
612                        tz,
613                        Ambiguous::Latest,
614                        NonExistent::Raise,
615                    )?
616                    .expect("we didn't use Ambiguous::Null or NonExistent::Null"))
617                } else {
618                    unreachable!()
619                }
620            },
621        }
622    }
623
624    fn truncate_subweekly<G, J>(
625        &self,
626        t: i64,
627        tz: Option<&Tz>,
628        duration: i64,
629        _timestamp_to_datetime: G,
630        _datetime_to_timestamp: J,
631    ) -> PolarsResult<i64>
632    where
633        G: Fn(i64) -> NaiveDateTime,
634        J: Fn(NaiveDateTime) -> i64,
635    {
636        match tz {
637            #[cfg(feature = "timezones")]
638            // for UTC, use fastpath below (same as naive)
639            Some(tz) if tz != &chrono_tz::UTC => {
640                let original_dt_utc = _timestamp_to_datetime(t);
641                let original_dt_local = unlocalize_datetime(original_dt_utc, tz);
642                let t = _datetime_to_timestamp(original_dt_local);
643                let mut remainder = t % duration;
644                if remainder < 0 {
645                    remainder += duration
646                }
647                let result_timestamp = t - remainder;
648                let result_dt_local = _timestamp_to_datetime(result_timestamp);
649                let result_dt_utc =
650                    self.localize_result(original_dt_local, original_dt_utc, result_dt_local, tz)?;
651                Ok(_datetime_to_timestamp(result_dt_utc))
652            },
653            _ => {
654                let mut remainder = t % duration;
655                if remainder < 0 {
656                    remainder += duration
657                }
658                Ok(t - remainder)
659            },
660        }
661    }
662
663    fn truncate_weekly<G, J>(
664        &self,
665        t: i64,
666        tz: Option<&Tz>,
667        _timestamp_to_datetime: G,
668        _datetime_to_timestamp: J,
669        daily_duration: i64,
670    ) -> PolarsResult<i64>
671    where
672        G: Fn(i64) -> NaiveDateTime,
673        J: Fn(NaiveDateTime) -> i64,
674    {
675        let _original_dt_utc: Option<NaiveDateTime>;
676        let _original_dt_local: Option<NaiveDateTime>;
677        let t = match tz {
678            #[cfg(feature = "timezones")]
679            // for UTC, use fastpath below (same as naive)
680            Some(tz) if tz != &chrono_tz::UTC => {
681                _original_dt_utc = Some(_timestamp_to_datetime(t));
682                _original_dt_local = Some(unlocalize_datetime(_original_dt_utc.unwrap(), tz));
683                _datetime_to_timestamp(_original_dt_local.unwrap())
684            },
685            _ => {
686                _original_dt_utc = None;
687                _original_dt_local = None;
688                t
689            },
690        };
691        // If we did
692        //   t - (t % (7 * self.weeks * daily_duration))
693        // then the timestamp would get truncated to the previous Thursday,
694        // because 1970-01-01 (timestamp 0) is a Thursday.
695        // So, we adjust by 4 days to get to Monday.
696        let mut remainder = (t - 4 * daily_duration) % (7 * self.weeks * daily_duration);
697        if remainder < 0 {
698            remainder += 7 * self.weeks * daily_duration
699        }
700        let result_t_local = t - remainder;
701        match tz {
702            #[cfg(feature = "timezones")]
703            // for UTC, use fastpath below (same as naive)
704            Some(tz) if tz != &chrono_tz::UTC => {
705                let result_dt_local = _timestamp_to_datetime(result_t_local);
706                let result_dt_utc = self.localize_result(
707                    _original_dt_local.unwrap(),
708                    _original_dt_utc.unwrap(),
709                    result_dt_local,
710                    tz,
711                )?;
712                Ok(_datetime_to_timestamp(result_dt_utc))
713            },
714            _ => Ok(result_t_local),
715        }
716    }
717    fn truncate_monthly<G, J>(
718        &self,
719        t: i64,
720        tz: Option<&Tz>,
721        timestamp_to_datetime: G,
722        datetime_to_timestamp: J,
723        daily_duration: i64,
724    ) -> PolarsResult<i64>
725    where
726        G: Fn(i64) -> NaiveDateTime,
727        J: Fn(NaiveDateTime) -> i64,
728    {
729        let original_dt_utc;
730        let original_dt_local;
731        let t = match tz {
732            #[cfg(feature = "timezones")]
733            // for UTC, use fastpath below (same as naive)
734            Some(tz) if tz != &chrono_tz::UTC => {
735                original_dt_utc = timestamp_to_datetime(t);
736                original_dt_local = unlocalize_datetime(original_dt_utc, tz);
737                datetime_to_timestamp(original_dt_local)
738            },
739            _ => {
740                original_dt_utc = timestamp_to_datetime(t);
741                original_dt_local = original_dt_utc;
742                datetime_to_timestamp(original_dt_local)
743            },
744        };
745
746        // Remove the time of day from the timestamp
747        // e.g. 2020-01-01 12:34:56 -> 2020-01-01 00:00:00
748        let mut remainder_time = t % daily_duration;
749        if remainder_time < 0 {
750            remainder_time += daily_duration
751        }
752        let t = t - remainder_time;
753
754        // Calculate how many months we need to subtract...
755        let (mut year, mut month) = (
756            original_dt_local.year() as i64,
757            original_dt_local.month() as i64,
758        );
759        let total = ((year - 1970) * 12) + (month - 1);
760        let mut remainder_months = total % self.months;
761        if remainder_months < 0 {
762            remainder_months += self.months
763        }
764
765        // ...and translate that to how many days we need to subtract.
766        let mut _is_leap_year = is_leap_year(year as i32);
767        let mut remainder_days = (original_dt_local.day() - 1) as i64;
768        while remainder_months > 12 {
769            let prev_year_is_leap_year = is_leap_year((year - 1) as i32);
770            let add_extra_day =
771                (_is_leap_year && month > 2) || (prev_year_is_leap_year && month <= 2);
772            remainder_days += 365 + add_extra_day as i64;
773            remainder_months -= 12;
774            year -= 1;
775            _is_leap_year = prev_year_is_leap_year;
776        }
777        while remainder_months > 0 {
778            month -= 1;
779            if month == 0 {
780                year -= 1;
781                _is_leap_year = is_leap_year(year as i32);
782                month = 12;
783            }
784            remainder_days += DAYS_PER_MONTH[_is_leap_year as usize][(month - 1) as usize];
785            remainder_months -= 1;
786        }
787
788        match tz {
789            #[cfg(feature = "timezones")]
790            // for UTC, use fastpath below (same as naive)
791            Some(tz) if tz != &chrono_tz::UTC => {
792                let result_dt_local = timestamp_to_datetime(t - remainder_days * daily_duration);
793                let result_dt_utc =
794                    self.localize_result(original_dt_local, original_dt_utc, result_dt_local, tz)?;
795                Ok(datetime_to_timestamp(result_dt_utc))
796            },
797            _ => Ok(t - remainder_days * daily_duration),
798        }
799    }
800
801    #[inline]
802    pub fn truncate_impl<F, G, J>(
803        &self,
804        t: i64,
805        tz: Option<&Tz>,
806        nsecs_to_unit: F,
807        timestamp_to_datetime: G,
808        datetime_to_timestamp: J,
809    ) -> PolarsResult<i64>
810    where
811        F: Fn(i64) -> i64,
812        G: Fn(i64) -> NaiveDateTime,
813        J: Fn(NaiveDateTime) -> i64,
814    {
815        match (self.months, self.weeks, self.days, self.nsecs) {
816            (0, 0, 0, 0) => polars_bail!(ComputeError: "duration cannot be zero"),
817            // truncate by ns/us/ms
818            (0, 0, 0, _) => {
819                let duration = nsecs_to_unit(self.nsecs);
820                if duration == 0 {
821                    return Ok(t);
822                }
823                self.truncate_subweekly(
824                    t,
825                    tz,
826                    duration,
827                    timestamp_to_datetime,
828                    datetime_to_timestamp,
829                )
830            },
831            // truncate by days
832            (0, 0, _, 0) => {
833                let duration = self.days * nsecs_to_unit(NS_DAY);
834                self.truncate_subweekly(
835                    t,
836                    tz,
837                    duration,
838                    timestamp_to_datetime,
839                    datetime_to_timestamp,
840                )
841            },
842            // truncate by weeks
843            (0, _, 0, 0) => {
844                let duration = nsecs_to_unit(NS_DAY);
845                self.truncate_weekly(
846                    t,
847                    tz,
848                    timestamp_to_datetime,
849                    datetime_to_timestamp,
850                    duration,
851                )
852            },
853            // truncate by months
854            (_, 0, 0, 0) => {
855                let duration = nsecs_to_unit(NS_DAY);
856                self.truncate_monthly(
857                    t,
858                    tz,
859                    timestamp_to_datetime,
860                    datetime_to_timestamp,
861                    duration,
862                )
863            },
864            _ => {
865                polars_bail!(ComputeError: "cannot mix month, week, day, and sub-daily units for this operation")
866            },
867        }
868    }
869
870    // Truncate the given ns timestamp by the window boundary.
871    #[inline]
872    pub fn truncate_ns(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {
873        self.truncate_impl(
874            t,
875            tz,
876            |nsecs| nsecs,
877            timestamp_ns_to_datetime,
878            datetime_to_timestamp_ns,
879        )
880    }
881
882    // Truncate the given ns timestamp by the window boundary.
883    #[inline]
884    pub fn truncate_us(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {
885        self.truncate_impl(
886            t,
887            tz,
888            |nsecs| nsecs / 1000,
889            timestamp_us_to_datetime,
890            datetime_to_timestamp_us,
891        )
892    }
893
894    // Truncate the given ms timestamp by the window boundary.
895    #[inline]
896    pub fn truncate_ms(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {
897        self.truncate_impl(
898            t,
899            tz,
900            |nsecs| nsecs / 1_000_000,
901            timestamp_ms_to_datetime,
902            datetime_to_timestamp_ms,
903        )
904    }
905
906    fn add_impl_month_week_or_day<F, G, J>(
907        &self,
908        mut t: i64,
909        tz: Option<&Tz>,
910        nsecs_to_unit: F,
911        timestamp_to_datetime: G,
912        datetime_to_timestamp: J,
913    ) -> PolarsResult<i64>
914    where
915        F: Fn(i64) -> i64,
916        G: Fn(i64) -> NaiveDateTime,
917        J: Fn(NaiveDateTime) -> i64,
918    {
919        let d = self;
920
921        if d.months > 0 {
922            let ts = match tz {
923                #[cfg(feature = "timezones")]
924                // for UTC, use fastpath below (same as naive)
925                Some(tz) if tz != &chrono_tz::UTC => {
926                    unlocalize_datetime(timestamp_to_datetime(t), tz)
927                },
928                _ => timestamp_to_datetime(t),
929            };
930            let dt = Self::add_month(ts, d.months, d.negative);
931            t = match tz {
932                #[cfg(feature = "timezones")]
933                // for UTC, use fastpath below (same as naive)
934                Some(tz) if tz != &chrono_tz::UTC => datetime_to_timestamp(
935                    try_localize_datetime(dt, tz, Ambiguous::Raise, NonExistent::Raise)?
936                        .expect("we didn't use Ambiguous::Null or NonExistent::Null"),
937                ),
938                _ => datetime_to_timestamp(dt),
939            };
940        }
941
942        if d.weeks > 0 {
943            let t_weeks = nsecs_to_unit(NS_WEEK) * self.weeks;
944            match tz {
945                #[cfg(feature = "timezones")]
946                // for UTC, use fastpath below (same as naive)
947                Some(tz) if tz != &chrono_tz::UTC => {
948                    t = datetime_to_timestamp(unlocalize_datetime(timestamp_to_datetime(t), tz));
949                    t += if d.negative { -t_weeks } else { t_weeks };
950                    t = datetime_to_timestamp(
951                        try_localize_datetime(
952                            timestamp_to_datetime(t),
953                            tz,
954                            Ambiguous::Raise,
955                            NonExistent::Raise,
956                        )?
957                        .expect("we didn't use Ambiguous::Null or NonExistent::Null"),
958                    );
959                },
960                _ => t += if d.negative { -t_weeks } else { t_weeks },
961            };
962        }
963
964        if d.days > 0 {
965            let t_days = nsecs_to_unit(NS_DAY) * self.days;
966            match tz {
967                #[cfg(feature = "timezones")]
968                // for UTC, use fastpath below (same as naive)
969                Some(tz) if tz != &chrono_tz::UTC => {
970                    t = datetime_to_timestamp(unlocalize_datetime(timestamp_to_datetime(t), tz));
971                    t += if d.negative { -t_days } else { t_days };
972                    t = datetime_to_timestamp(
973                        try_localize_datetime(
974                            timestamp_to_datetime(t),
975                            tz,
976                            Ambiguous::Raise,
977                            NonExistent::Raise,
978                        )?
979                        .expect("we didn't use Ambiguous::Null or NonExistent::Null"),
980                    );
981                },
982                _ => t += if d.negative { -t_days } else { t_days },
983            };
984        }
985
986        Ok(t)
987    }
988
989    pub fn add_ns(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {
990        let d = self;
991        let new_t = self.add_impl_month_week_or_day(
992            t,
993            tz,
994            |nsecs| nsecs,
995            timestamp_ns_to_datetime,
996            datetime_to_timestamp_ns,
997        );
998        let nsecs = if d.negative { -d.nsecs } else { d.nsecs };
999        Ok(new_t? + nsecs)
1000    }
1001
1002    pub fn add_us(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {
1003        let d = self;
1004        let new_t = self.add_impl_month_week_or_day(
1005            t,
1006            tz,
1007            |nsecs| nsecs / 1000,
1008            timestamp_us_to_datetime,
1009            datetime_to_timestamp_us,
1010        );
1011        let nsecs = if d.negative { -d.nsecs } else { d.nsecs };
1012        Ok(new_t? + nsecs / 1_000)
1013    }
1014
1015    pub fn add_ms(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {
1016        let d = self;
1017        let new_t = self.add_impl_month_week_or_day(
1018            t,
1019            tz,
1020            |nsecs| nsecs / 1_000_000,
1021            timestamp_ms_to_datetime,
1022            datetime_to_timestamp_ms,
1023        );
1024        let nsecs = if d.negative { -d.nsecs } else { d.nsecs };
1025        Ok(new_t? + nsecs / 1_000_000)
1026    }
1027}
1028
1029impl Mul<i64> for Duration {
1030    type Output = Self;
1031
1032    fn mul(mut self, mut rhs: i64) -> Self {
1033        if rhs < 0 {
1034            rhs = -rhs;
1035            self.negative = !self.negative
1036        }
1037        self.months *= rhs;
1038        self.weeks *= rhs;
1039        self.days *= rhs;
1040        self.nsecs *= rhs;
1041        self
1042    }
1043}
1044
1045fn new_datetime(
1046    year: i32,
1047    month: u32,
1048    days: u32,
1049    hour: u32,
1050    min: u32,
1051    sec: u32,
1052    nano: u32,
1053) -> Option<NaiveDateTime> {
1054    let date = NaiveDate::from_ymd_opt(year, month, days)?;
1055    let time = NaiveTime::from_hms_nano_opt(hour, min, sec, nano)?;
1056    Some(NaiveDateTime::new(date, time))
1057}
1058
1059pub fn ensure_is_constant_duration(
1060    duration: Duration,
1061    time_zone: Option<&TimeZone>,
1062    variable_name: &str,
1063) -> PolarsResult<()> {
1064    polars_ensure!(duration.is_constant_duration(time_zone),
1065        InvalidOperation: "expected `{}` to be a constant duration \
1066            (i.e. one independent of differing month durations or of daylight savings time), got {}.\n\
1067            \n\
1068            You may want to try:\n\
1069            - using `'730h'` instead of `'1mo'`\n\
1070            - using `'24h'` instead of `'1d'` if your series is time-zone-aware", variable_name, duration);
1071    Ok(())
1072}
1073
1074pub fn ensure_duration_matches_dtype(
1075    duration: Duration,
1076    dtype: &DataType,
1077    variable_name: &str,
1078) -> PolarsResult<()> {
1079    match dtype {
1080        DataType::Int64 | DataType::UInt64 | DataType::Int32 | DataType::UInt32 => {
1081            polars_ensure!(duration.parsed_int || duration.is_zero(),
1082                InvalidOperation: "`{}` duration must be a parsed integer (i.e. use '2i', not '2d') when working with a numeric column", variable_name);
1083        },
1084        DataType::Datetime(_, _) | DataType::Date | DataType::Duration(_) | DataType::Time => {
1085            polars_ensure!(!duration.parsed_int,
1086                InvalidOperation: "`{}` duration may not be a parsed integer (i.e. use '2d', not '2i') when working with a temporal column", variable_name);
1087        },
1088        _ => {
1089            polars_bail!(InvalidOperation: "unsupported data type: {} for temporal/index column, expected UInt64, UInt32, Int64, Int32, Datetime, Date, Duration, or Time", dtype)
1090        },
1091    }
1092    Ok(())
1093}
1094
1095#[cfg(test)]
1096mod test {
1097    use super::*;
1098
1099    #[test]
1100    fn test_parse() {
1101        let out = Duration::parse("1ns");
1102        assert_eq!(out.nsecs, 1);
1103        let out = Duration::parse("1ns1ms");
1104        assert_eq!(out.nsecs, NS_MILLISECOND + 1);
1105        let out = Duration::parse("123ns40ms");
1106        assert_eq!(out.nsecs, 40 * NS_MILLISECOND + 123);
1107        let out = Duration::parse("123ns40ms1w");
1108        assert_eq!(out.nsecs, 40 * NS_MILLISECOND + 123);
1109        assert_eq!(out.duration_ns(), 40 * NS_MILLISECOND + 123 + NS_WEEK);
1110        let out = Duration::parse("-123ns40ms1w");
1111        assert!(out.negative);
1112        let out = Duration::parse("5w");
1113        assert_eq!(out.weeks(), 5);
1114    }
1115
1116    #[test]
1117    fn test_parse_interval() {
1118        let d = Duration::try_parse_interval("3 DAYS").unwrap();
1119        assert_eq!(d.days(), 3);
1120
1121        let d = Duration::try_parse_interval("1 year, 2 months, 1 week").unwrap();
1122        assert_eq!(d.months(), 14);
1123        assert_eq!(d.weeks(), 1);
1124
1125        let d = Duration::try_parse_interval("100ms 100us").unwrap();
1126        assert_eq!(d.duration_us(), 100_100);
1127    }
1128
1129    #[test]
1130    fn test_add_ns() {
1131        let t = 1;
1132        let seven_days = Duration::parse("7d");
1133        let one_week = Duration::parse("1w");
1134
1135        // add_ns can only error if a time zone is passed, so it's
1136        // safe to unwrap here
1137        assert_eq!(
1138            seven_days.add_ns(t, None).unwrap(),
1139            one_week.add_ns(t, None).unwrap()
1140        );
1141
1142        let seven_days_negative = Duration::parse("-7d");
1143        let one_week_negative = Duration::parse("-1w");
1144
1145        // add_ns can only error if a time zone is passed, so it's
1146        // safe to unwrap here
1147        assert_eq!(
1148            seven_days_negative.add_ns(t, None).unwrap(),
1149            one_week_negative.add_ns(t, None).unwrap()
1150        );
1151    }
1152
1153    #[test]
1154    fn test_display() {
1155        let duration = Duration::parse("1h");
1156        let expected = "3600s";
1157        assert_eq!(format!("{duration}"), expected);
1158        let duration = Duration::parse("1h5ns");
1159        let expected = "3600000000005ns";
1160        assert_eq!(format!("{duration}"), expected);
1161        let duration = Duration::parse("1h5000ns");
1162        let expected = "3600000005us";
1163        assert_eq!(format!("{duration}"), expected);
1164        let duration = Duration::parse("3mo");
1165        let expected = "3mo";
1166        assert_eq!(format!("{duration}"), expected);
1167        let duration = Duration::parse_interval("4 weeks");
1168        let expected = "4w";
1169        assert_eq!(format!("{duration}"), expected);
1170    }
1171}