Path: blob/main/crates/polars-time/src/windows/duration.rs
6939 views
use std::cmp::Ordering;1use std::fmt::{Display, Formatter};2use std::ops::{Mul, Neg};34#[cfg(feature = "timezones")]5use arrow::legacy::kernels::{Ambiguous, NonExistent};6use arrow::legacy::time_zone::Tz;7use arrow::temporal_conversions::{8MICROSECONDS, MILLISECONDS, NANOSECONDS, timestamp_ms_to_datetime, timestamp_ns_to_datetime,9timestamp_us_to_datetime,10};11use chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime, Timelike};12use polars_core::datatypes::DataType;13use polars_core::prelude::{14PolarsResult, TimeZone, datetime_to_timestamp_ms, datetime_to_timestamp_ns,15datetime_to_timestamp_us, polars_bail,16};17use polars_error::polars_ensure;18#[cfg(feature = "serde")]19use serde::{Deserialize, Serialize};2021use super::calendar::{22NS_DAY, NS_HOUR, NS_MICROSECOND, NS_MILLISECOND, NS_MINUTE, NS_SECOND, NS_WEEK,23};24#[cfg(feature = "timezones")]25use crate::utils::{localize_datetime_opt, try_localize_datetime, unlocalize_datetime};26use crate::windows::calendar::{DAYS_PER_MONTH, is_leap_year};2728#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]29#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]30#[cfg_attr(feature = "dsl-schema", derive(schemars::JsonSchema))]31pub struct Duration {32// the number of months for the duration33months: i64,34// the number of weeks for the duration35weeks: i64,36// the number of days for the duration37days: i64,38// the number of nanoseconds for the duration39nsecs: i64,40// indicates if the duration is negative41pub(crate) negative: bool,42// indicates if an integer string was passed. e.g. "2i"43pub parsed_int: bool,44}4546impl PartialOrd<Self> for Duration {47fn partial_cmp(&self, other: &Self) -> Option<Ordering> {48Some(self.cmp(other))49}50}5152impl Ord for Duration {53fn cmp(&self, other: &Self) -> Ordering {54self.duration_ns().cmp(&other.duration_ns())55}56}5758impl Neg for Duration {59type Output = Self;6061fn neg(self) -> Self::Output {62Self {63months: self.months,64weeks: self.weeks,65days: self.days,66nsecs: self.nsecs,67negative: !self.negative,68parsed_int: self.parsed_int,69}70}71}7273impl Display for Duration {74fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {75if self.is_zero() {76return write!(f, "0s");77}78if self.negative {79write!(f, "-")?80}81if self.months > 0 {82write!(f, "{}mo", self.months)?83}84if self.weeks > 0 {85write!(f, "{}w", self.weeks)?86}87if self.days > 0 {88write!(f, "{}d", self.days)?89}90if self.nsecs > 0 {91let secs = self.nsecs / NANOSECONDS;92if secs * NANOSECONDS == self.nsecs {93write!(f, "{secs}s")?94} else {95let us = self.nsecs / 1_000;96if us * 1_000 == self.nsecs {97write!(f, "{us}us")?98} else {99write!(f, "{}ns", self.nsecs)?100}101}102}103Ok(())104}105}106107impl Duration {108/// Create a new integer size `Duration`109pub fn new(fixed_slots: i64) -> Self {110Duration {111months: 0,112weeks: 0,113days: 0,114nsecs: fixed_slots.abs(),115negative: fixed_slots < 0,116parsed_int: true,117}118}119120/// Parse a string into a `Duration`121///122/// Strings are composed of a sequence of number-unit pairs, such as `5d` (5 days). A string may begin with a minus123/// sign, in which case it is interpreted as a negative duration. Some examples:124///125/// * `"1y"`: 1 year126/// * `"-1w2d"`: negative 1 week, 2 days (i.e. -9 days)127/// * `"3d12h4m25s"`: 3 days, 12 hours, 4 minutes, and 25 seconds128///129/// Aside from a leading minus sign, strings may not contain any characters other than numbers and letters130/// (including whitespace).131///132/// The available units, in ascending order of magnitude, are as follows:133///134/// * `ns`: nanosecond135/// * `us`: microsecond136/// * `ms`: millisecond137/// * `s`: second138/// * `m`: minute139/// * `h`: hour140/// * `d`: day141/// * `w`: week142/// * `mo`: calendar month143/// * `q`: calendar quarter144/// * `y`: calendar year145/// * `i`: index value (only for {Int32, Int64} dtypes)146///147/// By "calendar day", we mean the corresponding time on the next148/// day (which may not be 24 hours, depending on daylight savings).149/// Similarly for "calendar week", "calendar month", "calendar quarter",150/// and "calendar year".151///152/// # Panics153/// If the given str is invalid for any reason.154pub fn parse(duration: &str) -> Self {155Self::try_parse(duration).unwrap()156}157158#[doc(hidden)]159/// Parse SQL-style "interval" string to Duration. Handles verbose160/// units (such as 'year', 'minutes', etc.) and whitespace, as161/// well as being case-insensitive.162pub fn parse_interval(interval: &str) -> Self {163Self::try_parse_interval(interval).unwrap()164}165166pub fn try_parse(duration: &str) -> PolarsResult<Self> {167Self::_parse(duration, false)168}169170pub fn try_parse_interval(interval: &str) -> PolarsResult<Self> {171Self::_parse(&interval.to_ascii_lowercase(), true)172}173174fn _parse(s: &str, as_interval: bool) -> PolarsResult<Self> {175let s = if as_interval { s.trim_start() } else { s };176177let parse_type = if as_interval { "interval" } else { "duration" };178let num_minus_signs = s.matches('-').count();179if num_minus_signs > 1 {180polars_bail!(InvalidOperation: "{} string can only have a single minus sign", parse_type);181}182if num_minus_signs > 0 {183if as_interval {184// TODO: intervals need to support per-element minus signs185polars_bail!(InvalidOperation: "minus signs are not currently supported in interval strings");186} else if !s.starts_with('-') {187polars_bail!(InvalidOperation: "only a single minus sign is allowed, at the front of the string");188}189}190let mut months = 0;191let mut weeks = 0;192let mut days = 0;193let mut nsecs = 0;194195let negative = s.starts_with('-');196let mut iter = s.char_indices().peekable();197let mut start = 0;198199// skip the '-' char200if negative {201start += 1;202iter.next().unwrap();203}204// permissive whitespace for intervals205if as_interval {206while let Some((i, ch)) = iter.peek() {207if *ch == ' ' {208start = *i + 1;209iter.next();210} else {211break;212}213}214}215// reserve capacity for the longest valid unit ("microseconds")216let mut unit = String::with_capacity(12);217let mut parsed_int = false;218let mut last_ch_opt: Option<char> = None;219220while let Some((i, mut ch)) = iter.next() {221if !ch.is_ascii_digit() {222let Ok(n) = s[start..i].parse::<i64>() else {223polars_bail!(InvalidOperation:224"expected leading integer in the {} string, found {}",225parse_type, ch226);227};228229loop {230match ch {231c if c.is_ascii_alphabetic() => unit.push(c),232' ' | ',' if as_interval => {},233_ => break,234}235match iter.next() {236Some((i, ch_)) => {237ch = ch_;238start = i239},240None => break,241}242}243if unit.is_empty() {244polars_bail!(InvalidOperation:245"expected a unit to follow integer in the {} string '{}'",246parse_type, s247);248}249match &*unit {250// matches that are allowed for both duration/interval251"ns" => nsecs += n,252"us" => nsecs += n * NS_MICROSECOND,253"ms" => nsecs += n * NS_MILLISECOND,254"s" => nsecs += n * NS_SECOND,255"m" => nsecs += n * NS_MINUTE,256"h" => nsecs += n * NS_HOUR,257"d" => days += n,258"w" => weeks += n,259"mo" => months += n,260"q" => months += n * 3,261"y" => months += n * 12,262"i" => {263nsecs += n;264parsed_int = true;265},266_ if as_interval => match &*unit {267// interval-only (verbose/sql) matches268"nanosecond" | "nanoseconds" => nsecs += n,269"microsecond" | "microseconds" => nsecs += n * NS_MICROSECOND,270"millisecond" | "milliseconds" => nsecs += n * NS_MILLISECOND,271"sec" | "secs" | "second" | "seconds" => nsecs += n * NS_SECOND,272"min" | "mins" | "minute" | "minutes" => nsecs += n * NS_MINUTE,273"hour" | "hours" => nsecs += n * NS_HOUR,274"day" | "days" => days += n,275"week" | "weeks" => weeks += n,276"mon" | "mons" | "month" | "months" => months += n,277"quarter" | "quarters" => months += n * 3,278"year" | "years" => months += n * 12,279_ => {280let valid_units = "'year', 'month', 'quarter', 'week', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond'";281polars_bail!(InvalidOperation: "unit: '{unit}' not supported; available units include: {} (and their plurals)", valid_units);282},283},284_ => {285polars_bail!(InvalidOperation: "unit: '{unit}' not supported; available units are: 'y', 'mo', 'q', 'w', 'd', 'h', 'm', 's', 'ms', 'us', 'ns'");286},287}288unit.clear();289}290last_ch_opt = Some(ch);291}292if let Some(last_ch) = last_ch_opt {293if last_ch.is_ascii_digit() {294polars_bail!(InvalidOperation:295"expected a unit to follow integer in the {} string '{}'",296parse_type, s297);298}299};300301Ok(Duration {302months: months.abs(),303weeks: weeks.abs(),304days: days.abs(),305nsecs: nsecs.abs(),306negative,307parsed_int,308})309}310311fn to_positive(v: i64) -> (bool, i64) {312if v < 0 { (true, -v) } else { (false, v) }313}314315/// Normalize the duration within the interval.316/// It will ensure that the output duration is the smallest positive317/// duration that is the equivalent of the current duration.318#[allow(dead_code)]319pub(crate) fn normalize(&self, interval: &Duration) -> Self {320if self.months_only() && interval.months_only() {321let mut months = self.months() % interval.months();322323match (self.negative, interval.negative) {324(true, true) | (true, false) => months = -months + interval.months(),325_ => {},326}327Duration::from_months(months)328} else if self.weeks_only() && interval.weeks_only() {329let mut weeks = self.weeks() % interval.weeks();330331match (self.negative, interval.negative) {332(true, true) | (true, false) => weeks = -weeks + interval.weeks(),333_ => {},334}335Duration::from_weeks(weeks)336} else if self.days_only() && interval.days_only() {337let mut days = self.days() % interval.days();338339match (self.negative, interval.negative) {340(true, true) | (true, false) => days = -days + interval.days(),341_ => {},342}343Duration::from_days(days)344} else {345let mut offset = self.duration_ns();346if offset == 0 {347return *self;348}349let every = interval.duration_ns();350351if offset < 0 {352offset += every * ((offset / -every) + 1)353} else {354offset -= every * (offset / every)355}356Duration::from_nsecs(offset)357}358}359360/// Creates a [`Duration`] that represents a fixed number of nanoseconds.361pub(crate) fn from_nsecs(v: i64) -> Self {362let (negative, nsecs) = Self::to_positive(v);363Self {364months: 0,365weeks: 0,366days: 0,367nsecs,368negative,369parsed_int: false,370}371}372373/// Creates a [`Duration`] that represents a fixed number of months.374pub(crate) fn from_months(v: i64) -> Self {375let (negative, months) = Self::to_positive(v);376Self {377months,378weeks: 0,379days: 0,380nsecs: 0,381negative,382parsed_int: false,383}384}385386/// Creates a [`Duration`] that represents a fixed number of weeks.387pub(crate) fn from_weeks(v: i64) -> Self {388let (negative, weeks) = Self::to_positive(v);389Self {390months: 0,391weeks,392days: 0,393nsecs: 0,394negative,395parsed_int: false,396}397}398399/// Creates a [`Duration`] that represents a fixed number of days.400pub(crate) fn from_days(v: i64) -> Self {401let (negative, days) = Self::to_positive(v);402Self {403months: 0,404weeks: 0,405days,406nsecs: 0,407negative,408parsed_int: false,409}410}411412/// `true` if zero duration.413pub fn is_zero(&self) -> bool {414self.months == 0 && self.weeks == 0 && self.days == 0 && self.nsecs == 0415}416417pub fn months_only(&self) -> bool {418self.months != 0 && self.weeks == 0 && self.days == 0 && self.nsecs == 0419}420421pub fn months(&self) -> i64 {422self.months423}424425pub fn weeks_only(&self) -> bool {426self.months == 0 && self.weeks != 0 && self.days == 0 && self.nsecs == 0427}428429pub fn weeks(&self) -> i64 {430self.weeks431}432433pub fn days_only(&self) -> bool {434self.months == 0 && self.weeks == 0 && self.days != 0 && self.nsecs == 0435}436437pub fn days(&self) -> i64 {438self.days439}440441/// Returns whether the duration consists of full days.442///443/// Note that 24 hours is not considered a full day due to possible444/// daylight savings time transitions.445pub fn is_full_days(&self) -> bool {446self.nsecs == 0447}448449pub fn is_constant_duration(&self, time_zone: Option<&TimeZone>) -> bool {450if time_zone.is_none() || time_zone == Some(&TimeZone::UTC) {451self.months == 0452} else {453// For non-native, non-UTC time zones, 1 calendar day is not454// necessarily 24 hours due to daylight savings time.455self.months == 0 && self.weeks == 0 && self.days == 0456}457}458459/// Returns the nanoseconds from the `Duration` without the weeks or months part.460pub fn nanoseconds(&self) -> i64 {461self.nsecs462}463464/// Returns whether duration is negative.465pub fn negative(&self) -> bool {466self.negative467}468469/// Estimated duration of the window duration. Not a very good one if not a constant duration.470#[doc(hidden)]471pub const fn duration_ns(&self) -> i64 {472self.months * 28 * 24 * 3600 * NANOSECONDS473+ self.weeks * NS_WEEK474+ self.days * NS_DAY475+ self.nsecs476}477478#[doc(hidden)]479pub const fn duration_us(&self) -> i64 {480self.months * 28 * 24 * 3600 * MICROSECONDS481+ (self.weeks * NS_WEEK / 1000 + self.nsecs / 1000 + self.days * NS_DAY / 1000)482}483484#[doc(hidden)]485pub const fn duration_ms(&self) -> i64 {486self.months * 28 * 24 * 3600 * MILLISECONDS487+ (self.weeks * NS_WEEK / 1_000_000488+ self.nsecs / 1_000_000489+ self.days * NS_DAY / 1_000_000)490}491492#[doc(hidden)]493fn add_month(ts: NaiveDateTime, n_months: i64, negative: bool) -> NaiveDateTime {494let mut months = n_months;495if negative {496months = -months;497}498499// Retrieve the current date and increment the values500// based on the number of months501let mut year = ts.year();502let mut month = ts.month() as i32;503let mut day = ts.day();504year += (months / 12) as i32;505month += (months % 12) as i32;506507// if the month overflowed or underflowed, adjust the year508// accordingly. Because we add the modulo for the months509// the year will only adjust by one510if month > 12 {511year += 1;512month -= 12;513} else if month <= 0 {514year -= 1;515month += 12;516}517518// Normalize the day if we are past the end of the month.519let last_day_of_month =520DAYS_PER_MONTH[is_leap_year(year) as usize][(month - 1) as usize] as u32;521522if day > last_day_of_month {523day = last_day_of_month524}525526// Retrieve the original time and construct a data527// with the new year, month and day528let hour = ts.hour();529let minute = ts.minute();530let sec = ts.second();531let nsec = ts.nanosecond();532new_datetime(year, month as u32, day, hour, minute, sec, nsec).expect(533"Expected valid datetime, please open an issue at https://github.com/pola-rs/polars/issues"534)535}536537/// Localize result to given time zone, respecting DST fold of original datetime.538/// For example, 2022-11-06 01:30:00 CST truncated by 1 hour becomes 2022-11-06 01:00:00 CST,539/// whereas 2022-11-06 01:30:00 CDT truncated by 1 hour becomes 2022-11-06 01:00:00 CDT.540///541/// * `original_dt_local` - original datetime, without time zone.542/// E.g. if the original datetime was 2022-11-06 01:30:00 CST, then this would543/// be 2022-11-06 01:30:00.544/// * `original_dt_utc` - original datetime converted to UTC. E.g. if the545/// original datetime was 2022-11-06 01:30:00 CST, then this would546/// be 2022-11-06 07:30:00.547/// * `result_dt_local` - result, without time zone.548#[cfg(feature = "timezones")]549fn localize_result(550&self,551original_dt_local: NaiveDateTime,552original_dt_utc: NaiveDateTime,553result_dt_local: NaiveDateTime,554tz: &Tz,555) -> PolarsResult<NaiveDateTime> {556match localize_datetime_opt(result_dt_local, tz, Ambiguous::Raise) {557Some(dt) => Ok(dt.expect("we didn't use Ambiguous::Null")),558None => {559if try_localize_datetime(560original_dt_local,561tz,562Ambiguous::Earliest,563NonExistent::Raise,564)?565.expect("we didn't use Ambiguous::Null or NonExistent::Null")566== original_dt_utc567{568Ok(try_localize_datetime(569result_dt_local,570tz,571Ambiguous::Earliest,572NonExistent::Raise,573)?574.expect("we didn't use Ambiguous::Null or NonExistent::Null"))575} else if try_localize_datetime(576original_dt_local,577tz,578Ambiguous::Latest,579NonExistent::Raise,580)?581.expect("we didn't use Ambiguous::Null or NonExistent::Null")582== original_dt_utc583{584Ok(try_localize_datetime(585result_dt_local,586tz,587Ambiguous::Latest,588NonExistent::Raise,589)?590.expect("we didn't use Ambiguous::Null or NonExistent::Null"))591} else {592unreachable!()593}594},595}596}597598fn truncate_subweekly<G, J>(599&self,600t: i64,601tz: Option<&Tz>,602duration: i64,603_timestamp_to_datetime: G,604_datetime_to_timestamp: J,605) -> PolarsResult<i64>606where607G: Fn(i64) -> NaiveDateTime,608J: Fn(NaiveDateTime) -> i64,609{610match tz {611#[cfg(feature = "timezones")]612// for UTC, use fastpath below (same as naive)613Some(tz) if tz != &chrono_tz::UTC => {614let original_dt_utc = _timestamp_to_datetime(t);615let original_dt_local = unlocalize_datetime(original_dt_utc, tz);616let t = _datetime_to_timestamp(original_dt_local);617let mut remainder = t % duration;618if remainder < 0 {619remainder += duration620}621let result_timestamp = t - remainder;622let result_dt_local = _timestamp_to_datetime(result_timestamp);623let result_dt_utc =624self.localize_result(original_dt_local, original_dt_utc, result_dt_local, tz)?;625Ok(_datetime_to_timestamp(result_dt_utc))626},627_ => {628let mut remainder = t % duration;629if remainder < 0 {630remainder += duration631}632Ok(t - remainder)633},634}635}636637fn truncate_weekly<G, J>(638&self,639t: i64,640tz: Option<&Tz>,641_timestamp_to_datetime: G,642_datetime_to_timestamp: J,643daily_duration: i64,644) -> PolarsResult<i64>645where646G: Fn(i64) -> NaiveDateTime,647J: Fn(NaiveDateTime) -> i64,648{649let _original_dt_utc: Option<NaiveDateTime>;650let _original_dt_local: Option<NaiveDateTime>;651let t = match tz {652#[cfg(feature = "timezones")]653// for UTC, use fastpath below (same as naive)654Some(tz) if tz != &chrono_tz::UTC => {655_original_dt_utc = Some(_timestamp_to_datetime(t));656_original_dt_local = Some(unlocalize_datetime(_original_dt_utc.unwrap(), tz));657_datetime_to_timestamp(_original_dt_local.unwrap())658},659_ => {660_original_dt_utc = None;661_original_dt_local = None;662t663},664};665// If we did666// t - (t % (7 * self.weeks * daily_duration))667// then the timestamp would get truncated to the previous Thursday,668// because 1970-01-01 (timestamp 0) is a Thursday.669// So, we adjust by 4 days to get to Monday.670let mut remainder = (t - 4 * daily_duration) % (7 * self.weeks * daily_duration);671if remainder < 0 {672remainder += 7 * self.weeks * daily_duration673}674let result_t_local = t - remainder;675match tz {676#[cfg(feature = "timezones")]677// for UTC, use fastpath below (same as naive)678Some(tz) if tz != &chrono_tz::UTC => {679let result_dt_local = _timestamp_to_datetime(result_t_local);680let result_dt_utc = self.localize_result(681_original_dt_local.unwrap(),682_original_dt_utc.unwrap(),683result_dt_local,684tz,685)?;686Ok(_datetime_to_timestamp(result_dt_utc))687},688_ => Ok(result_t_local),689}690}691fn truncate_monthly<G, J>(692&self,693t: i64,694tz: Option<&Tz>,695timestamp_to_datetime: G,696datetime_to_timestamp: J,697daily_duration: i64,698) -> PolarsResult<i64>699where700G: Fn(i64) -> NaiveDateTime,701J: Fn(NaiveDateTime) -> i64,702{703let original_dt_utc;704let original_dt_local;705let t = match tz {706#[cfg(feature = "timezones")]707// for UTC, use fastpath below (same as naive)708Some(tz) if tz != &chrono_tz::UTC => {709original_dt_utc = timestamp_to_datetime(t);710original_dt_local = unlocalize_datetime(original_dt_utc, tz);711datetime_to_timestamp(original_dt_local)712},713_ => {714original_dt_utc = timestamp_to_datetime(t);715original_dt_local = original_dt_utc;716datetime_to_timestamp(original_dt_local)717},718};719720// Remove the time of day from the timestamp721// e.g. 2020-01-01 12:34:56 -> 2020-01-01 00:00:00722let mut remainder_time = t % daily_duration;723if remainder_time < 0 {724remainder_time += daily_duration725}726let t = t - remainder_time;727728// Calculate how many months we need to subtract...729let (mut year, mut month) = (730original_dt_local.year() as i64,731original_dt_local.month() as i64,732);733let total = ((year - 1970) * 12) + (month - 1);734let mut remainder_months = total % self.months;735if remainder_months < 0 {736remainder_months += self.months737}738739// ...and translate that to how many days we need to subtract.740let mut _is_leap_year = is_leap_year(year as i32);741let mut remainder_days = (original_dt_local.day() - 1) as i64;742while remainder_months > 12 {743let prev_year_is_leap_year = is_leap_year((year - 1) as i32);744let add_extra_day =745(_is_leap_year && month > 2) || (prev_year_is_leap_year && month <= 2);746remainder_days += 365 + add_extra_day as i64;747remainder_months -= 12;748year -= 1;749_is_leap_year = prev_year_is_leap_year;750}751while remainder_months > 0 {752month -= 1;753if month == 0 {754year -= 1;755_is_leap_year = is_leap_year(year as i32);756month = 12;757}758remainder_days += DAYS_PER_MONTH[_is_leap_year as usize][(month - 1) as usize];759remainder_months -= 1;760}761762match tz {763#[cfg(feature = "timezones")]764// for UTC, use fastpath below (same as naive)765Some(tz) if tz != &chrono_tz::UTC => {766let result_dt_local = timestamp_to_datetime(t - remainder_days * daily_duration);767let result_dt_utc =768self.localize_result(original_dt_local, original_dt_utc, result_dt_local, tz)?;769Ok(datetime_to_timestamp(result_dt_utc))770},771_ => Ok(t - remainder_days * daily_duration),772}773}774775#[inline]776pub fn truncate_impl<F, G, J>(777&self,778t: i64,779tz: Option<&Tz>,780nsecs_to_unit: F,781timestamp_to_datetime: G,782datetime_to_timestamp: J,783) -> PolarsResult<i64>784where785F: Fn(i64) -> i64,786G: Fn(i64) -> NaiveDateTime,787J: Fn(NaiveDateTime) -> i64,788{789match (self.months, self.weeks, self.days, self.nsecs) {790(0, 0, 0, 0) => polars_bail!(ComputeError: "duration cannot be zero"),791// truncate by ns/us/ms792(0, 0, 0, _) => {793let duration = nsecs_to_unit(self.nsecs);794self.truncate_subweekly(795t,796tz,797duration,798timestamp_to_datetime,799datetime_to_timestamp,800)801},802// truncate by days803(0, 0, _, 0) => {804let duration = self.days * nsecs_to_unit(NS_DAY);805self.truncate_subweekly(806t,807tz,808duration,809timestamp_to_datetime,810datetime_to_timestamp,811)812},813// truncate by weeks814(0, _, 0, 0) => {815let duration = nsecs_to_unit(NS_DAY);816self.truncate_weekly(817t,818tz,819timestamp_to_datetime,820datetime_to_timestamp,821duration,822)823},824// truncate by months825(_, 0, 0, 0) => {826let duration = nsecs_to_unit(NS_DAY);827self.truncate_monthly(828t,829tz,830timestamp_to_datetime,831datetime_to_timestamp,832duration,833)834},835_ => {836polars_bail!(ComputeError: "cannot mix month, week, day, and sub-daily units for this operation")837},838}839}840841// Truncate the given ns timestamp by the window boundary.842#[inline]843pub fn truncate_ns(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {844self.truncate_impl(845t,846tz,847|nsecs| nsecs,848timestamp_ns_to_datetime,849datetime_to_timestamp_ns,850)851}852853// Truncate the given ns timestamp by the window boundary.854#[inline]855pub fn truncate_us(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {856self.truncate_impl(857t,858tz,859|nsecs| nsecs / 1000,860timestamp_us_to_datetime,861datetime_to_timestamp_us,862)863}864865// Truncate the given ms timestamp by the window boundary.866#[inline]867pub fn truncate_ms(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {868self.truncate_impl(869t,870tz,871|nsecs| nsecs / 1_000_000,872timestamp_ms_to_datetime,873datetime_to_timestamp_ms,874)875}876877fn add_impl_month_week_or_day<F, G, J>(878&self,879mut t: i64,880tz: Option<&Tz>,881nsecs_to_unit: F,882timestamp_to_datetime: G,883datetime_to_timestamp: J,884) -> PolarsResult<i64>885where886F: Fn(i64) -> i64,887G: Fn(i64) -> NaiveDateTime,888J: Fn(NaiveDateTime) -> i64,889{890let d = self;891892if d.months > 0 {893let ts = match tz {894#[cfg(feature = "timezones")]895// for UTC, use fastpath below (same as naive)896Some(tz) if tz != &chrono_tz::UTC => {897unlocalize_datetime(timestamp_to_datetime(t), tz)898},899_ => timestamp_to_datetime(t),900};901let dt = Self::add_month(ts, d.months, d.negative);902t = match tz {903#[cfg(feature = "timezones")]904// for UTC, use fastpath below (same as naive)905Some(tz) if tz != &chrono_tz::UTC => datetime_to_timestamp(906try_localize_datetime(dt, tz, Ambiguous::Raise, NonExistent::Raise)?907.expect("we didn't use Ambiguous::Null or NonExistent::Null"),908),909_ => datetime_to_timestamp(dt),910};911}912913if d.weeks > 0 {914let t_weeks = nsecs_to_unit(NS_WEEK) * self.weeks;915match tz {916#[cfg(feature = "timezones")]917// for UTC, use fastpath below (same as naive)918Some(tz) if tz != &chrono_tz::UTC => {919t = datetime_to_timestamp(unlocalize_datetime(timestamp_to_datetime(t), tz));920t += if d.negative { -t_weeks } else { t_weeks };921t = datetime_to_timestamp(922try_localize_datetime(923timestamp_to_datetime(t),924tz,925Ambiguous::Raise,926NonExistent::Raise,927)?928.expect("we didn't use Ambiguous::Null or NonExistent::Null"),929);930},931_ => t += if d.negative { -t_weeks } else { t_weeks },932};933}934935if d.days > 0 {936let t_days = nsecs_to_unit(NS_DAY) * self.days;937match tz {938#[cfg(feature = "timezones")]939// for UTC, use fastpath below (same as naive)940Some(tz) if tz != &chrono_tz::UTC => {941t = datetime_to_timestamp(unlocalize_datetime(timestamp_to_datetime(t), tz));942t += if d.negative { -t_days } else { t_days };943t = datetime_to_timestamp(944try_localize_datetime(945timestamp_to_datetime(t),946tz,947Ambiguous::Raise,948NonExistent::Raise,949)?950.expect("we didn't use Ambiguous::Null or NonExistent::Null"),951);952},953_ => t += if d.negative { -t_days } else { t_days },954};955}956957Ok(t)958}959960pub fn add_ns(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {961let d = self;962let new_t = self.add_impl_month_week_or_day(963t,964tz,965|nsecs| nsecs,966timestamp_ns_to_datetime,967datetime_to_timestamp_ns,968);969let nsecs = if d.negative { -d.nsecs } else { d.nsecs };970Ok(new_t? + nsecs)971}972973pub fn add_us(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {974let d = self;975let new_t = self.add_impl_month_week_or_day(976t,977tz,978|nsecs| nsecs / 1000,979timestamp_us_to_datetime,980datetime_to_timestamp_us,981);982let nsecs = if d.negative { -d.nsecs } else { d.nsecs };983Ok(new_t? + nsecs / 1_000)984}985986pub fn add_ms(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {987let d = self;988let new_t = self.add_impl_month_week_or_day(989t,990tz,991|nsecs| nsecs / 1_000_000,992timestamp_ms_to_datetime,993datetime_to_timestamp_ms,994);995let nsecs = if d.negative { -d.nsecs } else { d.nsecs };996Ok(new_t? + nsecs / 1_000_000)997}998}9991000impl Mul<i64> for Duration {1001type Output = Self;10021003fn mul(mut self, mut rhs: i64) -> Self {1004if rhs < 0 {1005rhs = -rhs;1006self.negative = !self.negative1007}1008self.months *= rhs;1009self.weeks *= rhs;1010self.days *= rhs;1011self.nsecs *= rhs;1012self1013}1014}10151016fn new_datetime(1017year: i32,1018month: u32,1019days: u32,1020hour: u32,1021min: u32,1022sec: u32,1023nano: u32,1024) -> Option<NaiveDateTime> {1025let date = NaiveDate::from_ymd_opt(year, month, days)?;1026let time = NaiveTime::from_hms_nano_opt(hour, min, sec, nano)?;1027Some(NaiveDateTime::new(date, time))1028}10291030pub fn ensure_is_constant_duration(1031duration: Duration,1032time_zone: Option<&TimeZone>,1033variable_name: &str,1034) -> PolarsResult<()> {1035polars_ensure!(duration.is_constant_duration(time_zone),1036InvalidOperation: "expected `{}` to be a constant duration \1037(i.e. one independent of differing month durations or of daylight savings time), got {}.\n\1038\n\1039You may want to try:\n\1040- using `'730h'` instead of `'1mo'`\n\1041- using `'24h'` instead of `'1d'` if your series is time-zone-aware", variable_name, duration);1042Ok(())1043}10441045pub fn ensure_duration_matches_dtype(1046duration: Duration,1047dtype: &DataType,1048variable_name: &str,1049) -> PolarsResult<()> {1050match dtype {1051DataType::Int64 | DataType::UInt64 | DataType::Int32 | DataType::UInt32 => {1052polars_ensure!(duration.parsed_int || duration.is_zero(),1053InvalidOperation: "`{}` duration must be a parsed integer (i.e. use '2i', not '2d') when working with a numeric column", variable_name);1054},1055DataType::Datetime(_, _) | DataType::Date | DataType::Duration(_) | DataType::Time => {1056polars_ensure!(!duration.parsed_int,1057InvalidOperation: "`{}` duration may not be a parsed integer (i.e. use '2d', not '2i') when working with a temporal column", variable_name);1058},1059_ => {1060polars_bail!(InvalidOperation: "unsupported data type: {} for temporal/index column, expected UInt64, UInt32, Int64, Int32, Datetime, Date, Duration, or Time", dtype)1061},1062}1063Ok(())1064}10651066#[cfg(test)]1067mod test {1068use super::*;10691070#[test]1071fn test_parse() {1072let out = Duration::parse("1ns");1073assert_eq!(out.nsecs, 1);1074let out = Duration::parse("1ns1ms");1075assert_eq!(out.nsecs, NS_MILLISECOND + 1);1076let out = Duration::parse("123ns40ms");1077assert_eq!(out.nsecs, 40 * NS_MILLISECOND + 123);1078let out = Duration::parse("123ns40ms1w");1079assert_eq!(out.nsecs, 40 * NS_MILLISECOND + 123);1080assert_eq!(out.duration_ns(), 40 * NS_MILLISECOND + 123 + NS_WEEK);1081let out = Duration::parse("-123ns40ms1w");1082assert!(out.negative);1083let out = Duration::parse("5w");1084assert_eq!(out.weeks(), 5);1085}10861087#[test]1088fn test_add_ns() {1089let t = 1;1090let seven_days = Duration::parse("7d");1091let one_week = Duration::parse("1w");10921093// add_ns can only error if a time zone is passed, so it's1094// safe to unwrap here1095assert_eq!(1096seven_days.add_ns(t, None).unwrap(),1097one_week.add_ns(t, None).unwrap()1098);10991100let seven_days_negative = Duration::parse("-7d");1101let one_week_negative = Duration::parse("-1w");11021103// add_ns can only error if a time zone is passed, so it's1104// safe to unwrap here1105assert_eq!(1106seven_days_negative.add_ns(t, None).unwrap(),1107one_week_negative.add_ns(t, None).unwrap()1108);1109}11101111#[test]1112fn test_display() {1113let duration = Duration::parse("1h");1114let expected = "3600s";1115assert_eq!(format!("{duration}"), expected);1116let duration = Duration::parse("1h5ns");1117let expected = "3600000000005ns";1118assert_eq!(format!("{duration}"), expected);1119let duration = Duration::parse("1h5000ns");1120let expected = "3600000005us";1121assert_eq!(format!("{duration}"), expected);1122let duration = Duration::parse("3mo");1123let expected = "3mo";1124assert_eq!(format!("{duration}"), expected);1125let duration = Duration::parse_interval("4 weeks");1126let expected = "4w";1127assert_eq!(format!("{duration}"), expected);1128}1129}113011311132