polars_time/windows/
duration.rs

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