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
178        let parse_type = if as_interval { "interval" } else { "duration" };
179        let num_minus_signs = s.matches('-').count();
180        if num_minus_signs > 1 {
181            polars_bail!(InvalidOperation: "{} string can only have a single minus sign", parse_type);
182        }
183        if num_minus_signs > 0 {
184            if as_interval {
185                // TODO: intervals need to support per-element minus signs
186                polars_bail!(InvalidOperation: "minus signs are not currently supported in interval strings");
187            } else if !s.starts_with('-') {
188                polars_bail!(InvalidOperation: "only a single minus sign is allowed, at the front of the string");
189            }
190        }
191        let mut months = 0;
192        let mut weeks = 0;
193        let mut days = 0;
194        let mut nsecs = 0;
195
196        let negative = s.starts_with('-');
197        let mut iter = s.char_indices().peekable();
198        let mut start = 0;
199
200        // skip the '-' char
201        if negative {
202            start += 1;
203            iter.next().unwrap();
204        }
205        // permissive whitespace for intervals
206        if as_interval {
207            while let Some((i, ch)) = iter.peek() {
208                if *ch == ' ' {
209                    start = *i + 1;
210                    iter.next();
211                } else {
212                    break;
213                }
214            }
215        }
216        // reserve capacity for the longest valid unit ("microseconds")
217        let mut unit = String::with_capacity(12);
218        let mut parsed_int = false;
219        let mut last_ch_opt: Option<char> = None;
220
221        while let Some((i, mut ch)) = iter.next() {
222            if !ch.is_ascii_digit() {
223                let Ok(n) = s[start..i].parse::<i64>() else {
224                    polars_bail!(InvalidOperation:
225                        "expected leading integer in the {} string, found {}",
226                        parse_type, ch
227                    );
228                };
229
230                loop {
231                    match ch {
232                        c if c.is_ascii_alphabetic() => unit.push(c),
233                        ' ' | ',' if as_interval => {},
234                        _ => break,
235                    }
236                    match iter.next() {
237                        Some((i, ch_)) => {
238                            ch = ch_;
239                            start = i
240                        },
241                        None => break,
242                    }
243                }
244                if unit.is_empty() {
245                    polars_bail!(InvalidOperation:
246                        "expected a unit to follow integer in the {} string '{}'",
247                        parse_type, s
248                    );
249                }
250                match &*unit {
251                    // matches that are allowed for both duration/interval
252                    "ns" => nsecs += n,
253                    "us" => nsecs += n * NS_MICROSECOND,
254                    "ms" => nsecs += n * NS_MILLISECOND,
255                    "s" => nsecs += n * NS_SECOND,
256                    "m" => nsecs += n * NS_MINUTE,
257                    "h" => nsecs += n * NS_HOUR,
258                    "d" => days += n,
259                    "w" => weeks += n,
260                    "mo" => months += n,
261                    "q" => months += n * 3,
262                    "y" => months += n * 12,
263                    "i" => {
264                        nsecs += n;
265                        parsed_int = true;
266                    },
267                    _ if as_interval => match &*unit {
268                        // interval-only (verbose/sql) matches
269                        "nanosecond" | "nanoseconds" => nsecs += n,
270                        "microsecond" | "microseconds" => nsecs += n * NS_MICROSECOND,
271                        "millisecond" | "milliseconds" => nsecs += n * NS_MILLISECOND,
272                        "sec" | "secs" | "second" | "seconds" => nsecs += n * NS_SECOND,
273                        "min" | "mins" | "minute" | "minutes" => nsecs += n * NS_MINUTE,
274                        "hour" | "hours" => nsecs += n * NS_HOUR,
275                        "day" | "days" => days += n,
276                        "week" | "weeks" => weeks += n,
277                        "mon" | "mons" | "month" | "months" => months += n,
278                        "quarter" | "quarters" => months += n * 3,
279                        "year" | "years" => months += n * 12,
280                        _ => {
281                            let valid_units = "'year', 'month', 'quarter', 'week', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond'";
282                            polars_bail!(InvalidOperation: "unit: '{unit}' not supported; available units include: {} (and their plurals)", valid_units);
283                        },
284                    },
285                    _ => {
286                        polars_bail!(InvalidOperation: "unit: '{unit}' not supported; available units are: 'y', 'mo', 'q', 'w', 'd', 'h', 'm', 's', 'ms', 'us', 'ns'");
287                    },
288                }
289                unit.clear();
290            }
291            last_ch_opt = Some(ch);
292        }
293        if let Some(last_ch) = last_ch_opt {
294            if last_ch.is_ascii_digit() {
295                polars_bail!(InvalidOperation:
296                    "expected a unit to follow integer in the {} string '{}'",
297                    parse_type, s
298                );
299            }
300        };
301
302        Ok(Duration {
303            months: months.abs(),
304            weeks: weeks.abs(),
305            days: days.abs(),
306            nsecs: nsecs.abs(),
307            negative,
308            parsed_int,
309        })
310    }
311
312    fn to_positive(v: i64) -> (bool, i64) {
313        if v < 0 { (true, -v) } else { (false, v) }
314    }
315
316    /// Normalize the duration within the interval.
317    /// It will ensure that the output duration is the smallest positive
318    /// duration that is the equivalent of the current duration.
319    #[allow(dead_code)]
320    pub(crate) fn normalize(&self, interval: &Duration) -> Self {
321        if self.months_only() && interval.months_only() {
322            let mut months = self.months() % interval.months();
323
324            match (self.negative, interval.negative) {
325                (true, true) | (true, false) => months = -months + interval.months(),
326                _ => {},
327            }
328            Duration::from_months(months)
329        } else if self.weeks_only() && interval.weeks_only() {
330            let mut weeks = self.weeks() % interval.weeks();
331
332            match (self.negative, interval.negative) {
333                (true, true) | (true, false) => weeks = -weeks + interval.weeks(),
334                _ => {},
335            }
336            Duration::from_weeks(weeks)
337        } else if self.days_only() && interval.days_only() {
338            let mut days = self.days() % interval.days();
339
340            match (self.negative, interval.negative) {
341                (true, true) | (true, false) => days = -days + interval.days(),
342                _ => {},
343            }
344            Duration::from_days(days)
345        } else {
346            let mut offset = self.duration_ns();
347            if offset == 0 {
348                return *self;
349            }
350            let every = interval.duration_ns();
351
352            if offset < 0 {
353                offset += every * ((offset / -every) + 1)
354            } else {
355                offset -= every * (offset / every)
356            }
357            Duration::from_nsecs(offset)
358        }
359    }
360
361    /// Creates a [`Duration`] that represents a fixed number of nanoseconds.
362    pub(crate) fn from_nsecs(v: i64) -> Self {
363        let (negative, nsecs) = Self::to_positive(v);
364        Self {
365            months: 0,
366            weeks: 0,
367            days: 0,
368            nsecs,
369            negative,
370            parsed_int: false,
371        }
372    }
373
374    /// Creates a [`Duration`] that represents a fixed number of months.
375    pub(crate) fn from_months(v: i64) -> Self {
376        let (negative, months) = Self::to_positive(v);
377        Self {
378            months,
379            weeks: 0,
380            days: 0,
381            nsecs: 0,
382            negative,
383            parsed_int: false,
384        }
385    }
386
387    /// Creates a [`Duration`] that represents a fixed number of weeks.
388    pub(crate) fn from_weeks(v: i64) -> Self {
389        let (negative, weeks) = Self::to_positive(v);
390        Self {
391            months: 0,
392            weeks,
393            days: 0,
394            nsecs: 0,
395            negative,
396            parsed_int: false,
397        }
398    }
399
400    /// Creates a [`Duration`] that represents a fixed number of days.
401    pub(crate) fn from_days(v: i64) -> Self {
402        let (negative, days) = Self::to_positive(v);
403        Self {
404            months: 0,
405            weeks: 0,
406            days,
407            nsecs: 0,
408            negative,
409            parsed_int: false,
410        }
411    }
412
413    /// `true` if zero duration.
414    pub fn is_zero(&self) -> bool {
415        self.months == 0 && self.weeks == 0 && self.days == 0 && self.nsecs == 0
416    }
417
418    pub fn months_only(&self) -> bool {
419        self.months != 0 && self.weeks == 0 && self.days == 0 && self.nsecs == 0
420    }
421
422    pub fn months(&self) -> i64 {
423        self.months
424    }
425
426    pub fn weeks_only(&self) -> bool {
427        self.months == 0 && self.weeks != 0 && self.days == 0 && self.nsecs == 0
428    }
429
430    pub fn weeks(&self) -> i64 {
431        self.weeks
432    }
433
434    pub fn days_only(&self) -> bool {
435        self.months == 0 && self.weeks == 0 && self.days != 0 && self.nsecs == 0
436    }
437
438    pub fn days(&self) -> i64 {
439        self.days
440    }
441
442    /// Returns whether the duration consists of full days.
443    ///
444    /// Note that 24 hours is not considered a full day due to possible
445    /// daylight savings time transitions.
446    pub fn is_full_days(&self) -> bool {
447        self.nsecs == 0
448    }
449
450    pub fn is_constant_duration(&self, time_zone: Option<&TimeZone>) -> bool {
451        if time_zone.is_none() || time_zone == Some(&TimeZone::UTC) {
452            self.months == 0
453        } else {
454            // For non-native, non-UTC time zones, 1 calendar day is not
455            // necessarily 24 hours due to daylight savings time.
456            self.months == 0 && self.weeks == 0 && self.days == 0
457        }
458    }
459
460    /// Returns the nanoseconds from the `Duration` without the weeks or months part.
461    pub fn nanoseconds(&self) -> i64 {
462        self.nsecs
463    }
464
465    /// Returns whether duration is negative.
466    pub fn negative(&self) -> bool {
467        self.negative
468    }
469
470    /// Estimated duration of the window duration. Not a very good one if not a constant duration.
471    #[doc(hidden)]
472    pub const fn duration_ns(&self) -> i64 {
473        self.months * 28 * 24 * 3600 * NANOSECONDS
474            + self.weeks * NS_WEEK
475            + self.days * NS_DAY
476            + self.nsecs
477    }
478
479    #[doc(hidden)]
480    pub const fn duration_us(&self) -> i64 {
481        self.months * 28 * 24 * 3600 * MICROSECONDS
482            + (self.weeks * NS_WEEK / 1000 + self.nsecs / 1000 + self.days * NS_DAY / 1000)
483    }
484
485    #[doc(hidden)]
486    pub const fn duration_ms(&self) -> i64 {
487        self.months * 28 * 24 * 3600 * MILLISECONDS
488            + (self.weeks * NS_WEEK / 1_000_000
489                + self.nsecs / 1_000_000
490                + self.days * NS_DAY / 1_000_000)
491    }
492
493    #[doc(hidden)]
494    fn add_month(ts: NaiveDateTime, n_months: i64, negative: bool) -> NaiveDateTime {
495        let mut months = n_months;
496        if negative {
497            months = -months;
498        }
499
500        // Retrieve the current date and increment the values
501        // based on the number of months
502        let mut year = ts.year();
503        let mut month = ts.month() as i32;
504        let mut day = ts.day();
505        year += (months / 12) as i32;
506        month += (months % 12) as i32;
507
508        // if the month overflowed or underflowed, adjust the year
509        // accordingly. Because we add the modulo for the months
510        // the year will only adjust by one
511        if month > 12 {
512            year += 1;
513            month -= 12;
514        } else if month <= 0 {
515            year -= 1;
516            month += 12;
517        }
518
519        // Normalize the day if we are past the end of the month.
520        let last_day_of_month =
521            DAYS_PER_MONTH[is_leap_year(year) as usize][(month - 1) as usize] as u32;
522
523        if day > last_day_of_month {
524            day = last_day_of_month
525        }
526
527        // Retrieve the original time and construct a data
528        // with the new year, month and day
529        let hour = ts.hour();
530        let minute = ts.minute();
531        let sec = ts.second();
532        let nsec = ts.nanosecond();
533        new_datetime(year, month as u32, day, hour, minute, sec, nsec).expect(
534            "Expected valid datetime, please open an issue at https://github.com/pola-rs/polars/issues"
535        )
536    }
537
538    /// Localize result to given time zone, respecting DST fold of original datetime.
539    /// For example, 2022-11-06 01:30:00 CST truncated by 1 hour becomes 2022-11-06 01:00:00 CST,
540    /// whereas 2022-11-06 01:30:00 CDT truncated by 1 hour becomes 2022-11-06 01:00:00 CDT.
541    ///
542    /// * `original_dt_local` - original datetime, without time zone.
543    ///   E.g. if the original datetime was 2022-11-06 01:30:00 CST, then this would
544    ///   be 2022-11-06 01:30:00.
545    /// * `original_dt_utc` - original datetime converted to UTC. E.g. if the
546    ///   original datetime was 2022-11-06 01:30:00 CST, then this would
547    ///   be 2022-11-06 07:30:00.
548    /// * `result_dt_local` - result, without time zone.
549    #[cfg(feature = "timezones")]
550    fn localize_result(
551        &self,
552        original_dt_local: NaiveDateTime,
553        original_dt_utc: NaiveDateTime,
554        result_dt_local: NaiveDateTime,
555        tz: &Tz,
556    ) -> PolarsResult<NaiveDateTime> {
557        match localize_datetime_opt(result_dt_local, tz, Ambiguous::Raise) {
558            Some(dt) => Ok(dt.expect("we didn't use Ambiguous::Null")),
559            None => {
560                if try_localize_datetime(
561                    original_dt_local,
562                    tz,
563                    Ambiguous::Earliest,
564                    NonExistent::Raise,
565                )?
566                .expect("we didn't use Ambiguous::Null or NonExistent::Null")
567                    == original_dt_utc
568                {
569                    Ok(try_localize_datetime(
570                        result_dt_local,
571                        tz,
572                        Ambiguous::Earliest,
573                        NonExistent::Raise,
574                    )?
575                    .expect("we didn't use Ambiguous::Null or NonExistent::Null"))
576                } else if try_localize_datetime(
577                    original_dt_local,
578                    tz,
579                    Ambiguous::Latest,
580                    NonExistent::Raise,
581                )?
582                .expect("we didn't use Ambiguous::Null or NonExistent::Null")
583                    == original_dt_utc
584                {
585                    Ok(try_localize_datetime(
586                        result_dt_local,
587                        tz,
588                        Ambiguous::Latest,
589                        NonExistent::Raise,
590                    )?
591                    .expect("we didn't use Ambiguous::Null or NonExistent::Null"))
592                } else {
593                    unreachable!()
594                }
595            },
596        }
597    }
598
599    fn truncate_subweekly<G, J>(
600        &self,
601        t: i64,
602        tz: Option<&Tz>,
603        duration: i64,
604        _timestamp_to_datetime: G,
605        _datetime_to_timestamp: J,
606    ) -> PolarsResult<i64>
607    where
608        G: Fn(i64) -> NaiveDateTime,
609        J: Fn(NaiveDateTime) -> i64,
610    {
611        match tz {
612            #[cfg(feature = "timezones")]
613            // for UTC, use fastpath below (same as naive)
614            Some(tz) if tz != &chrono_tz::UTC => {
615                let original_dt_utc = _timestamp_to_datetime(t);
616                let original_dt_local = unlocalize_datetime(original_dt_utc, tz);
617                let t = _datetime_to_timestamp(original_dt_local);
618                let mut remainder = t % duration;
619                if remainder < 0 {
620                    remainder += duration
621                }
622                let result_timestamp = t - remainder;
623                let result_dt_local = _timestamp_to_datetime(result_timestamp);
624                let result_dt_utc =
625                    self.localize_result(original_dt_local, original_dt_utc, result_dt_local, tz)?;
626                Ok(_datetime_to_timestamp(result_dt_utc))
627            },
628            _ => {
629                let mut remainder = t % duration;
630                if remainder < 0 {
631                    remainder += duration
632                }
633                Ok(t - remainder)
634            },
635        }
636    }
637
638    fn truncate_weekly<G, J>(
639        &self,
640        t: i64,
641        tz: Option<&Tz>,
642        _timestamp_to_datetime: G,
643        _datetime_to_timestamp: J,
644        daily_duration: i64,
645    ) -> PolarsResult<i64>
646    where
647        G: Fn(i64) -> NaiveDateTime,
648        J: Fn(NaiveDateTime) -> i64,
649    {
650        let _original_dt_utc: Option<NaiveDateTime>;
651        let _original_dt_local: Option<NaiveDateTime>;
652        let t = match tz {
653            #[cfg(feature = "timezones")]
654            // for UTC, use fastpath below (same as naive)
655            Some(tz) if tz != &chrono_tz::UTC => {
656                _original_dt_utc = Some(_timestamp_to_datetime(t));
657                _original_dt_local = Some(unlocalize_datetime(_original_dt_utc.unwrap(), tz));
658                _datetime_to_timestamp(_original_dt_local.unwrap())
659            },
660            _ => {
661                _original_dt_utc = None;
662                _original_dt_local = None;
663                t
664            },
665        };
666        // If we did
667        //   t - (t % (7 * self.weeks * daily_duration))
668        // then the timestamp would get truncated to the previous Thursday,
669        // because 1970-01-01 (timestamp 0) is a Thursday.
670        // So, we adjust by 4 days to get to Monday.
671        let mut remainder = (t - 4 * daily_duration) % (7 * self.weeks * daily_duration);
672        if remainder < 0 {
673            remainder += 7 * self.weeks * daily_duration
674        }
675        let result_t_local = t - remainder;
676        match tz {
677            #[cfg(feature = "timezones")]
678            // for UTC, use fastpath below (same as naive)
679            Some(tz) if tz != &chrono_tz::UTC => {
680                let result_dt_local = _timestamp_to_datetime(result_t_local);
681                let result_dt_utc = self.localize_result(
682                    _original_dt_local.unwrap(),
683                    _original_dt_utc.unwrap(),
684                    result_dt_local,
685                    tz,
686                )?;
687                Ok(_datetime_to_timestamp(result_dt_utc))
688            },
689            _ => Ok(result_t_local),
690        }
691    }
692    fn truncate_monthly<G, J>(
693        &self,
694        t: i64,
695        tz: Option<&Tz>,
696        timestamp_to_datetime: G,
697        datetime_to_timestamp: J,
698        daily_duration: i64,
699    ) -> PolarsResult<i64>
700    where
701        G: Fn(i64) -> NaiveDateTime,
702        J: Fn(NaiveDateTime) -> i64,
703    {
704        let original_dt_utc;
705        let original_dt_local;
706        let t = match tz {
707            #[cfg(feature = "timezones")]
708            // for UTC, use fastpath below (same as naive)
709            Some(tz) if tz != &chrono_tz::UTC => {
710                original_dt_utc = timestamp_to_datetime(t);
711                original_dt_local = unlocalize_datetime(original_dt_utc, tz);
712                datetime_to_timestamp(original_dt_local)
713            },
714            _ => {
715                original_dt_utc = timestamp_to_datetime(t);
716                original_dt_local = original_dt_utc;
717                datetime_to_timestamp(original_dt_local)
718            },
719        };
720
721        // Remove the time of day from the timestamp
722        // e.g. 2020-01-01 12:34:56 -> 2020-01-01 00:00:00
723        let mut remainder_time = t % daily_duration;
724        if remainder_time < 0 {
725            remainder_time += daily_duration
726        }
727        let t = t - remainder_time;
728
729        // Calculate how many months we need to subtract...
730        let (mut year, mut month) = (
731            original_dt_local.year() as i64,
732            original_dt_local.month() as i64,
733        );
734        let total = ((year - 1970) * 12) + (month - 1);
735        let mut remainder_months = total % self.months;
736        if remainder_months < 0 {
737            remainder_months += self.months
738        }
739
740        // ...and translate that to how many days we need to subtract.
741        let mut _is_leap_year = is_leap_year(year as i32);
742        let mut remainder_days = (original_dt_local.day() - 1) as i64;
743        while remainder_months > 12 {
744            let prev_year_is_leap_year = is_leap_year((year - 1) as i32);
745            let add_extra_day =
746                (_is_leap_year && month > 2) || (prev_year_is_leap_year && month <= 2);
747            remainder_days += 365 + add_extra_day as i64;
748            remainder_months -= 12;
749            year -= 1;
750            _is_leap_year = prev_year_is_leap_year;
751        }
752        while remainder_months > 0 {
753            month -= 1;
754            if month == 0 {
755                year -= 1;
756                _is_leap_year = is_leap_year(year as i32);
757                month = 12;
758            }
759            remainder_days += DAYS_PER_MONTH[_is_leap_year as usize][(month - 1) as usize];
760            remainder_months -= 1;
761        }
762
763        match tz {
764            #[cfg(feature = "timezones")]
765            // for UTC, use fastpath below (same as naive)
766            Some(tz) if tz != &chrono_tz::UTC => {
767                let result_dt_local = timestamp_to_datetime(t - remainder_days * daily_duration);
768                let result_dt_utc =
769                    self.localize_result(original_dt_local, original_dt_utc, result_dt_local, tz)?;
770                Ok(datetime_to_timestamp(result_dt_utc))
771            },
772            _ => Ok(t - remainder_days * daily_duration),
773        }
774    }
775
776    #[inline]
777    pub fn truncate_impl<F, G, J>(
778        &self,
779        t: i64,
780        tz: Option<&Tz>,
781        nsecs_to_unit: F,
782        timestamp_to_datetime: G,
783        datetime_to_timestamp: J,
784    ) -> PolarsResult<i64>
785    where
786        F: Fn(i64) -> i64,
787        G: Fn(i64) -> NaiveDateTime,
788        J: Fn(NaiveDateTime) -> i64,
789    {
790        match (self.months, self.weeks, self.days, self.nsecs) {
791            (0, 0, 0, 0) => polars_bail!(ComputeError: "duration cannot be zero"),
792            // truncate by ns/us/ms
793            (0, 0, 0, _) => {
794                let duration = nsecs_to_unit(self.nsecs);
795                self.truncate_subweekly(
796                    t,
797                    tz,
798                    duration,
799                    timestamp_to_datetime,
800                    datetime_to_timestamp,
801                )
802            },
803            // truncate by days
804            (0, 0, _, 0) => {
805                let duration = self.days * nsecs_to_unit(NS_DAY);
806                self.truncate_subweekly(
807                    t,
808                    tz,
809                    duration,
810                    timestamp_to_datetime,
811                    datetime_to_timestamp,
812                )
813            },
814            // truncate by weeks
815            (0, _, 0, 0) => {
816                let duration = nsecs_to_unit(NS_DAY);
817                self.truncate_weekly(
818                    t,
819                    tz,
820                    timestamp_to_datetime,
821                    datetime_to_timestamp,
822                    duration,
823                )
824            },
825            // truncate by months
826            (_, 0, 0, 0) => {
827                let duration = nsecs_to_unit(NS_DAY);
828                self.truncate_monthly(
829                    t,
830                    tz,
831                    timestamp_to_datetime,
832                    datetime_to_timestamp,
833                    duration,
834                )
835            },
836            _ => {
837                polars_bail!(ComputeError: "cannot mix month, week, day, and sub-daily units for this operation")
838            },
839        }
840    }
841
842    // Truncate the given ns timestamp by the window boundary.
843    #[inline]
844    pub fn truncate_ns(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {
845        self.truncate_impl(
846            t,
847            tz,
848            |nsecs| nsecs,
849            timestamp_ns_to_datetime,
850            datetime_to_timestamp_ns,
851        )
852    }
853
854    // Truncate the given ns timestamp by the window boundary.
855    #[inline]
856    pub fn truncate_us(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {
857        self.truncate_impl(
858            t,
859            tz,
860            |nsecs| nsecs / 1000,
861            timestamp_us_to_datetime,
862            datetime_to_timestamp_us,
863        )
864    }
865
866    // Truncate the given ms timestamp by the window boundary.
867    #[inline]
868    pub fn truncate_ms(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {
869        self.truncate_impl(
870            t,
871            tz,
872            |nsecs| nsecs / 1_000_000,
873            timestamp_ms_to_datetime,
874            datetime_to_timestamp_ms,
875        )
876    }
877
878    fn add_impl_month_week_or_day<F, G, J>(
879        &self,
880        mut t: i64,
881        tz: Option<&Tz>,
882        nsecs_to_unit: F,
883        timestamp_to_datetime: G,
884        datetime_to_timestamp: J,
885    ) -> PolarsResult<i64>
886    where
887        F: Fn(i64) -> i64,
888        G: Fn(i64) -> NaiveDateTime,
889        J: Fn(NaiveDateTime) -> i64,
890    {
891        let d = self;
892
893        if d.months > 0 {
894            let ts = match tz {
895                #[cfg(feature = "timezones")]
896                // for UTC, use fastpath below (same as naive)
897                Some(tz) if tz != &chrono_tz::UTC => {
898                    unlocalize_datetime(timestamp_to_datetime(t), tz)
899                },
900                _ => timestamp_to_datetime(t),
901            };
902            let dt = Self::add_month(ts, d.months, d.negative);
903            t = match tz {
904                #[cfg(feature = "timezones")]
905                // for UTC, use fastpath below (same as naive)
906                Some(tz) if tz != &chrono_tz::UTC => datetime_to_timestamp(
907                    try_localize_datetime(dt, tz, Ambiguous::Raise, NonExistent::Raise)?
908                        .expect("we didn't use Ambiguous::Null or NonExistent::Null"),
909                ),
910                _ => datetime_to_timestamp(dt),
911            };
912        }
913
914        if d.weeks > 0 {
915            let t_weeks = nsecs_to_unit(NS_WEEK) * self.weeks;
916            match tz {
917                #[cfg(feature = "timezones")]
918                // for UTC, use fastpath below (same as naive)
919                Some(tz) if tz != &chrono_tz::UTC => {
920                    t = datetime_to_timestamp(unlocalize_datetime(timestamp_to_datetime(t), tz));
921                    t += if d.negative { -t_weeks } else { t_weeks };
922                    t = datetime_to_timestamp(
923                        try_localize_datetime(
924                            timestamp_to_datetime(t),
925                            tz,
926                            Ambiguous::Raise,
927                            NonExistent::Raise,
928                        )?
929                        .expect("we didn't use Ambiguous::Null or NonExistent::Null"),
930                    );
931                },
932                _ => t += if d.negative { -t_weeks } else { t_weeks },
933            };
934        }
935
936        if d.days > 0 {
937            let t_days = nsecs_to_unit(NS_DAY) * self.days;
938            match tz {
939                #[cfg(feature = "timezones")]
940                // for UTC, use fastpath below (same as naive)
941                Some(tz) if tz != &chrono_tz::UTC => {
942                    t = datetime_to_timestamp(unlocalize_datetime(timestamp_to_datetime(t), tz));
943                    t += if d.negative { -t_days } else { t_days };
944                    t = datetime_to_timestamp(
945                        try_localize_datetime(
946                            timestamp_to_datetime(t),
947                            tz,
948                            Ambiguous::Raise,
949                            NonExistent::Raise,
950                        )?
951                        .expect("we didn't use Ambiguous::Null or NonExistent::Null"),
952                    );
953                },
954                _ => t += if d.negative { -t_days } else { t_days },
955            };
956        }
957
958        Ok(t)
959    }
960
961    pub fn add_ns(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {
962        let d = self;
963        let new_t = self.add_impl_month_week_or_day(
964            t,
965            tz,
966            |nsecs| nsecs,
967            timestamp_ns_to_datetime,
968            datetime_to_timestamp_ns,
969        );
970        let nsecs = if d.negative { -d.nsecs } else { d.nsecs };
971        Ok(new_t? + nsecs)
972    }
973
974    pub fn add_us(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {
975        let d = self;
976        let new_t = self.add_impl_month_week_or_day(
977            t,
978            tz,
979            |nsecs| nsecs / 1000,
980            timestamp_us_to_datetime,
981            datetime_to_timestamp_us,
982        );
983        let nsecs = if d.negative { -d.nsecs } else { d.nsecs };
984        Ok(new_t? + nsecs / 1_000)
985    }
986
987    pub fn add_ms(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {
988        let d = self;
989        let new_t = self.add_impl_month_week_or_day(
990            t,
991            tz,
992            |nsecs| nsecs / 1_000_000,
993            timestamp_ms_to_datetime,
994            datetime_to_timestamp_ms,
995        );
996        let nsecs = if d.negative { -d.nsecs } else { d.nsecs };
997        Ok(new_t? + nsecs / 1_000_000)
998    }
999}
1000
1001impl Mul<i64> for Duration {
1002    type Output = Self;
1003
1004    fn mul(mut self, mut rhs: i64) -> Self {
1005        if rhs < 0 {
1006            rhs = -rhs;
1007            self.negative = !self.negative
1008        }
1009        self.months *= rhs;
1010        self.weeks *= rhs;
1011        self.days *= rhs;
1012        self.nsecs *= rhs;
1013        self
1014    }
1015}
1016
1017fn new_datetime(
1018    year: i32,
1019    month: u32,
1020    days: u32,
1021    hour: u32,
1022    min: u32,
1023    sec: u32,
1024    nano: u32,
1025) -> Option<NaiveDateTime> {
1026    let date = NaiveDate::from_ymd_opt(year, month, days)?;
1027    let time = NaiveTime::from_hms_nano_opt(hour, min, sec, nano)?;
1028    Some(NaiveDateTime::new(date, time))
1029}
1030
1031pub fn ensure_is_constant_duration(
1032    duration: Duration,
1033    time_zone: Option<&TimeZone>,
1034    variable_name: &str,
1035) -> PolarsResult<()> {
1036    polars_ensure!(duration.is_constant_duration(time_zone),
1037        InvalidOperation: "expected `{}` to be a constant duration \
1038            (i.e. one independent of differing month durations or of daylight savings time), got {}.\n\
1039            \n\
1040            You may want to try:\n\
1041            - using `'730h'` instead of `'1mo'`\n\
1042            - using `'24h'` instead of `'1d'` if your series is time-zone-aware", variable_name, duration);
1043    Ok(())
1044}
1045
1046pub fn ensure_duration_matches_dtype(
1047    duration: Duration,
1048    dtype: &DataType,
1049    variable_name: &str,
1050) -> PolarsResult<()> {
1051    match dtype {
1052        DataType::Int64 | DataType::UInt64 | DataType::Int32 | DataType::UInt32 => {
1053            polars_ensure!(duration.parsed_int || duration.is_zero(),
1054                InvalidOperation: "`{}` duration must be a parsed integer (i.e. use '2i', not '2d') when working with a numeric column", variable_name);
1055        },
1056        DataType::Datetime(_, _) | DataType::Date | DataType::Duration(_) | DataType::Time => {
1057            polars_ensure!(!duration.parsed_int,
1058                InvalidOperation: "`{}` duration may not be a parsed integer (i.e. use '2d', not '2i') when working with a temporal column", variable_name);
1059        },
1060        _ => {
1061            polars_bail!(InvalidOperation: "unsupported data type: {} for temporal/index column, expected UInt64, UInt32, Int64, Int32, Datetime, Date, Duration, or Time", dtype)
1062        },
1063    }
1064    Ok(())
1065}
1066
1067#[cfg(test)]
1068mod test {
1069    use super::*;
1070
1071    #[test]
1072    fn test_parse() {
1073        let out = Duration::parse("1ns");
1074        assert_eq!(out.nsecs, 1);
1075        let out = Duration::parse("1ns1ms");
1076        assert_eq!(out.nsecs, NS_MILLISECOND + 1);
1077        let out = Duration::parse("123ns40ms");
1078        assert_eq!(out.nsecs, 40 * NS_MILLISECOND + 123);
1079        let out = Duration::parse("123ns40ms1w");
1080        assert_eq!(out.nsecs, 40 * NS_MILLISECOND + 123);
1081        assert_eq!(out.duration_ns(), 40 * NS_MILLISECOND + 123 + NS_WEEK);
1082        let out = Duration::parse("-123ns40ms1w");
1083        assert!(out.negative);
1084        let out = Duration::parse("5w");
1085        assert_eq!(out.weeks(), 5);
1086    }
1087
1088    #[test]
1089    fn test_add_ns() {
1090        let t = 1;
1091        let seven_days = Duration::parse("7d");
1092        let one_week = Duration::parse("1w");
1093
1094        // add_ns can only error if a time zone is passed, so it's
1095        // safe to unwrap here
1096        assert_eq!(
1097            seven_days.add_ns(t, None).unwrap(),
1098            one_week.add_ns(t, None).unwrap()
1099        );
1100
1101        let seven_days_negative = Duration::parse("-7d");
1102        let one_week_negative = Duration::parse("-1w");
1103
1104        // add_ns can only error if a time zone is passed, so it's
1105        // safe to unwrap here
1106        assert_eq!(
1107            seven_days_negative.add_ns(t, None).unwrap(),
1108            one_week_negative.add_ns(t, None).unwrap()
1109        );
1110    }
1111
1112    #[test]
1113    fn test_display() {
1114        let duration = Duration::parse("1h");
1115        let expected = "3600s";
1116        assert_eq!(format!("{duration}"), expected);
1117        let duration = Duration::parse("1h5ns");
1118        let expected = "3600000000005ns";
1119        assert_eq!(format!("{duration}"), expected);
1120        let duration = Duration::parse("1h5000ns");
1121        let expected = "3600000005us";
1122        assert_eq!(format!("{duration}"), expected);
1123        let duration = Duration::parse("3mo");
1124        let expected = "3mo";
1125        assert_eq!(format!("{duration}"), expected);
1126        let duration = Duration::parse_interval("4 weeks");
1127        let expected = "4w";
1128        assert_eq!(format!("{duration}"), expected);
1129    }
1130}