Path: blob/main/crates/polars-time/src/windows/duration.rs
8424 views
use std::cmp::Ordering;1use std::fmt::{Display, Formatter};2use std::ops::{Add, Mul, Neg};34use arrow::legacy::time_zone::Tz;5use arrow::temporal_conversions::{6MICROSECONDS, MILLISECONDS, NANOSECONDS, timestamp_ms_to_datetime, timestamp_ns_to_datetime,7timestamp_us_to_datetime,8};9#[cfg(feature = "timezones")]10use chrono::TimeZone as ChronoTimeZone;11#[cfg(feature = "timezones")]12use chrono::offset::LocalResult;13use chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime, TimeDelta, Timelike};14#[cfg(feature = "timezones")]15use chrono_tz::OffsetComponents;16use polars_core::datatypes::DataType;17use polars_core::prelude::{18Ambiguous, NonExistent, PolarsResult, TimeZone, datetime_to_timestamp_ms,19datetime_to_timestamp_ns, datetime_to_timestamp_us, polars_bail,20};21use polars_error::polars_ensure;22#[cfg(feature = "serde")]23use serde::{Deserialize, Serialize};2425use super::calendar::{26NS_DAY, NS_HOUR, NS_MICROSECOND, NS_MILLISECOND, NS_MINUTE, NS_SECOND, NS_WEEK, NTE_NS_DAY,27NTE_NS_WEEK,28};29#[cfg(feature = "timezones")]30use crate::utils::try_localize_datetime;31#[cfg(feature = "timezones")]32use crate::utils::unlocalize_datetime;33use crate::windows::calendar::{DAYS_PER_MONTH, is_leap_year};3435#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]36#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]37#[cfg_attr(feature = "dsl-schema", derive(schemars::JsonSchema))]38pub struct Duration {39// the number of months for the duration40months: i64,41// the number of weeks for the duration42weeks: i64,43// the number of days for the duration44days: i64,45// the number of nanoseconds for the duration46nsecs: i64,47// indicates if the duration is negative48pub(crate) negative: bool,49// indicates if an integer string was passed. e.g. "2i"50pub parsed_int: bool,51}5253impl PartialOrd<Self> for Duration {54fn partial_cmp(&self, other: &Self) -> Option<Ordering> {55Some(self.cmp(other))56}57}5859impl Ord for Duration {60fn cmp(&self, other: &Self) -> Ordering {61self.duration_ns().cmp(&other.duration_ns())62}63}6465impl Neg for Duration {66type Output = Self;6768fn neg(self) -> Self::Output {69Self {70months: self.months,71weeks: self.weeks,72days: self.days,73nsecs: self.nsecs,74negative: !self.negative,75parsed_int: self.parsed_int,76}77}78}7980impl Display for Duration {81fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {82if self.is_zero() {83return write!(f, "0s");84}85if self.negative {86write!(f, "-")?87}88if self.months > 0 {89write!(f, "{}mo", self.months)?90}91if self.weeks > 0 {92write!(f, "{}w", self.weeks)?93}94if self.days > 0 {95write!(f, "{}d", self.days)?96}97if self.nsecs > 0 {98let secs = self.nsecs / NANOSECONDS;99if secs * NANOSECONDS == self.nsecs {100write!(f, "{secs}s")?101} else {102let us = self.nsecs / 1_000;103if us * 1_000 == self.nsecs {104write!(f, "{us}us")?105} else {106write!(f, "{}ns", self.nsecs)?107}108}109}110Ok(())111}112}113114impl Duration {115/// Create a new integer size `Duration`116pub const fn new(fixed_slots: i64) -> Self {117Duration {118months: 0,119weeks: 0,120days: 0,121nsecs: fixed_slots.abs(),122negative: fixed_slots < 0,123parsed_int: true,124}125}126127/// Parse a string into a `Duration`128///129/// Strings are composed of a sequence of number-unit pairs, such as `5d` (5 days). A string may begin with a minus130/// sign, in which case it is interpreted as a negative duration. Some examples:131///132/// * `"1y"`: 1 year133/// * `"-1w2d"`: negative 1 week, 2 days (i.e. -9 days)134/// * `"3d12h4m25s"`: 3 days, 12 hours, 4 minutes, and 25 seconds135///136/// Aside from a leading minus sign, strings may not contain any characters other than numbers and letters137/// (including whitespace).138///139/// The available units, in ascending order of magnitude, are as follows:140///141/// * `ns`: nanosecond142/// * `us`: microsecond143/// * `ms`: millisecond144/// * `s`: second145/// * `m`: minute146/// * `h`: hour147/// * `d`: day148/// * `w`: week149/// * `mo`: calendar month150/// * `q`: calendar quarter151/// * `y`: calendar year152/// * `i`: index value (only for {Int32, Int64} dtypes)153///154/// By "calendar day", we mean the corresponding time on the next155/// day (which may not be 24 hours, depending on daylight savings).156/// Similarly for "calendar week", "calendar month", "calendar quarter",157/// and "calendar year".158///159/// # Panics160/// If the given str is invalid for any reason.161pub fn parse(duration: &str) -> Self {162Self::try_parse(duration).unwrap()163}164165#[doc(hidden)]166/// Parse SQL-style "interval" string to Duration. Handles verbose167/// units (such as 'year', 'minutes', etc.) and whitespace, as168/// well as being case-insensitive.169pub fn parse_interval(interval: &str) -> Self {170Self::try_parse_interval(interval).unwrap()171}172173pub fn try_parse(duration: &str) -> PolarsResult<Self> {174Self::_parse(duration, false)175}176177pub fn try_parse_interval(interval: &str) -> PolarsResult<Self> {178Self::_parse(&interval.to_ascii_lowercase(), true)179}180181fn _parse(s: &str, as_interval: bool) -> PolarsResult<Self> {182let s = if as_interval { s.trim_start() } else { s };183let parse_type = if as_interval { "interval" } else { "duration" };184185// can work on raw bytes (much faster), as valid interval/duration strings are all ASCII186let original_string = s;187let s = s.as_bytes();188let mut pos = 0;189190// check for an initial '+'/'-' char191let (leading_minus, leading_plus) = match s.first() {192Some(&b'-') => (true, false),193Some(&b'+') => (false, true),194_ => (false, false),195};196197// if leading '+'/'-' found, consume it198if leading_minus || leading_plus {199pos += 1;200}201202// permissive whitespace for intervals203if as_interval {204while pos < s.len() && s[pos] == b' ' {205pos += 1;206}207}208209// we only allow/expect a single leading '+' or '-' char210macro_rules! error_on_second_plus_minus {211($ch:expr) => {{212let previously_seen = if $ch == b'-' { leading_minus } else { leading_plus };213if previously_seen {214polars_bail!(InvalidOperation: "{} string can only have a single '{}' sign", parse_type, $ch as char);215}216let sign = if $ch == b'-' { "minus" } else { "plus" };217if as_interval {218polars_bail!(InvalidOperation: "{} signs are not currently supported in interval strings", sign);219} else {220polars_bail!(InvalidOperation: "only a single {} sign is allowed, at the front of the string", sign);221}222}};223}224225// walk the byte-string, identifying number-unit pairs226let mut parsed_int = false;227let mut months = 0;228let mut weeks = 0;229let mut days = 0;230let mut nsecs = 0;231232while pos < s.len() {233let ch = s[pos];234if !ch.is_ascii_digit() {235if ch == b'-' || ch == b'+' {236error_on_second_plus_minus!(ch);237}238polars_bail!(InvalidOperation:239"expected leading integer in the {} string, found '{}'",240parse_type, ch as char241);242}243244// get integer value from the raw bytes245let mut n = 0i64;246while pos < s.len() && s[pos].is_ascii_digit() {247n = n * 10 + (s[pos] - b'0') as i64;248pos += 1;249}250if pos >= s.len() {251polars_bail!(InvalidOperation:252"expected a valid unit to follow integer in the {} string '{}'",253parse_type, original_string254);255}256257// skip leading whitespace/commas before unit (for intervals)258if as_interval {259while pos < s.len() && (s[pos] == b' ' || s[pos] == b',') {260pos += 1;261}262}263264// parse the unit associated with the given integer value265let unit_start = pos;266while pos < s.len() && s[pos].is_ascii_alphabetic() {267pos += 1;268}269let unit_end = pos;270if unit_start == unit_end {271polars_bail!(InvalidOperation:272"expected a valid unit to follow integer in the {} string '{}'",273parse_type, original_string274);275}276277// only valid location for '+'/'-' chars is at the start278if pos < s.len() && (s[pos] == b'-' || s[pos] == b'+') {279error_on_second_plus_minus!(s[pos]);280}281282// skip any whitespace/comma that follows an interval unit283if as_interval {284while pos < s.len() && (s[pos] == b' ' || s[pos] == b',') {285pos += 1;286}287}288289let unit = &s[unit_start..unit_end];290match unit {291// matches that are allowed for both duration and interval292b"ns" => nsecs += n,293b"us" => nsecs += n * NS_MICROSECOND,294b"ms" => nsecs += n * NS_MILLISECOND,295b"s" => nsecs += n * NS_SECOND,296b"m" => nsecs += n * NS_MINUTE,297b"h" => nsecs += n * NS_HOUR,298b"d" => days += n,299b"w" => weeks += n,300b"mo" => months += n,301b"q" => months += n * 3,302b"y" => months += n * 12,303b"i" => {304nsecs += n;305parsed_int = true;306},307// interval-only (verbose/sql) matches308_ if as_interval => match unit {309b"nanosecond" | b"nanoseconds" => nsecs += n,310b"microsecond" | b"microseconds" => nsecs += n * NS_MICROSECOND,311b"millisecond" | b"milliseconds" => nsecs += n * NS_MILLISECOND,312b"sec" | b"secs" | b"second" | b"seconds" => nsecs += n * NS_SECOND,313b"min" | b"mins" | b"minute" | b"minutes" => nsecs += n * NS_MINUTE,314b"hour" | b"hours" => nsecs += n * NS_HOUR,315b"day" | b"days" => days += n,316b"week" | b"weeks" => weeks += n,317b"mon" | b"mons" | b"month" | b"months" => months += n,318b"quarter" | b"quarters" => months += n * 3,319b"year" | b"years" => months += n * 12,320_ => {321let unit_str = std::str::from_utf8(unit).unwrap_or("<invalid>");322let valid_units = "'year', 'month', 'quarter', 'week', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond'";323polars_bail!(InvalidOperation: "unit: '{}' not supported; available units include: {} (and their plurals)", unit_str, valid_units);324},325},326_ => {327let unit_str = std::str::from_utf8(unit).unwrap_or("<invalid>");328polars_bail!(InvalidOperation: "unit: '{}' not supported; available units are: 'y', 'mo', 'q', 'w', 'd', 'h', 'm', 's', 'ms', 'us', 'ns'", unit_str);329},330}331}332333Ok(Duration {334months: months.abs(),335weeks: weeks.abs(),336days: days.abs(),337nsecs: nsecs.abs(),338negative: leading_minus,339parsed_int,340})341}342343fn to_positive(v: i64) -> (bool, i64) {344if v < 0 { (true, -v) } else { (false, v) }345}346347/// Normalize the duration within the interval.348/// It will ensure that the output duration is the smallest positive349/// duration that is the equivalent of the current duration.350#[allow(dead_code)]351pub(crate) fn normalize(&self, interval: &Duration) -> Self {352if self.months_only() && interval.months_only() {353let mut months = self.months() % interval.months();354355match (self.negative, interval.negative) {356(true, true) | (true, false) => months = -months + interval.months(),357_ => {},358}359Duration::from_months(months)360} else if self.weeks_only() && interval.weeks_only() {361let mut weeks = self.weeks() % interval.weeks();362363match (self.negative, interval.negative) {364(true, true) | (true, false) => weeks = -weeks + interval.weeks(),365_ => {},366}367Duration::from_weeks(weeks)368} else if self.days_only() && interval.days_only() {369let mut days = self.days() % interval.days();370371match (self.negative, interval.negative) {372(true, true) | (true, false) => days = -days + interval.days(),373_ => {},374}375Duration::from_days(days)376} else {377let mut offset = self.duration_ns();378if offset == 0 {379return *self;380}381let every = interval.duration_ns();382383if offset < 0 {384offset += every * ((offset / -every) + 1)385} else {386offset -= every * (offset / every)387}388Duration::from_nsecs(offset)389}390}391392/// Creates a [`Duration`] that represents a fixed number of nanoseconds.393pub(crate) fn from_nsecs(v: i64) -> Self {394let (negative, nsecs) = Self::to_positive(v);395Self {396months: 0,397weeks: 0,398days: 0,399nsecs,400negative,401parsed_int: false,402}403}404405/// Creates a [`Duration`] that represents a fixed number of months.406pub(crate) fn from_months(v: i64) -> Self {407let (negative, months) = Self::to_positive(v);408Self {409months,410weeks: 0,411days: 0,412nsecs: 0,413negative,414parsed_int: false,415}416}417418/// Creates a [`Duration`] that represents a fixed number of weeks.419pub(crate) fn from_weeks(v: i64) -> Self {420let (negative, weeks) = Self::to_positive(v);421Self {422months: 0,423weeks,424days: 0,425nsecs: 0,426negative,427parsed_int: false,428}429}430431/// Creates a [`Duration`] that represents a fixed number of days.432pub(crate) fn from_days(v: i64) -> Self {433let (negative, days) = Self::to_positive(v);434Self {435months: 0,436weeks: 0,437days,438nsecs: 0,439negative,440parsed_int: false,441}442}443444/// `true` if zero duration.445pub fn is_zero(&self) -> bool {446self.months == 0 && self.weeks == 0 && self.days == 0 && self.nsecs == 0447}448449pub fn months_only(&self) -> bool {450self.months != 0 && self.weeks == 0 && self.days == 0 && self.nsecs == 0451}452453pub fn months(&self) -> i64 {454self.months455}456457pub fn weeks_only(&self) -> bool {458self.months == 0 && self.weeks != 0 && self.days == 0 && self.nsecs == 0459}460461pub fn weeks(&self) -> i64 {462self.weeks463}464465pub fn days_only(&self) -> bool {466self.months == 0 && self.weeks == 0 && self.days != 0 && self.nsecs == 0467}468469pub fn days(&self) -> i64 {470self.days471}472473/// Returns whether the duration consists of full days.474///475/// Note that 24 hours is not considered a full day due to possible476/// daylight savings time transitions.477pub fn is_full_days(&self) -> bool {478self.nsecs == 0479}480481pub fn is_constant_duration(&self, time_zone: Option<&TimeZone>) -> bool {482if time_zone.is_none() || time_zone == Some(&TimeZone::UTC) {483self.months == 0484} else {485// For non-native, non-UTC time zones, 1 calendar day is not486// necessarily 24 hours due to daylight savings time.487self.months == 0 && self.weeks == 0 && self.days == 0488}489}490491/// Returns the nanoseconds from the `Duration` without the weeks or months part.492pub fn nanoseconds(&self) -> i64 {493self.nsecs494}495496/// Returns whether duration is negative.497pub fn negative(&self) -> bool {498self.negative499}500501/// Estimated duration of the window duration. Not a very good one if not a constant duration.502#[doc(hidden)]503pub const fn duration_ns(&self) -> i64 {504self.months * 28 * 24 * 3600 * NANOSECONDS505+ self.weeks * NS_WEEK506+ self.days * NS_DAY507+ self.nsecs508}509510#[doc(hidden)]511pub const fn duration_us(&self) -> i64 {512self.months * 28 * 24 * 3600 * MICROSECONDS513+ (self.weeks * NS_WEEK / 1000 + self.nsecs / 1000 + self.days * NS_DAY / 1000)514}515516#[doc(hidden)]517pub const fn duration_ms(&self) -> i64 {518self.months * 28 * 24 * 3600 * MILLISECONDS519+ (self.weeks * NS_WEEK / 1_000_000520+ self.nsecs / 1_000_000521+ self.days * NS_DAY / 1_000_000)522}523524/// Not-to-exceed estimated duration of the window duration. The actual duration will be525/// less or equal than the estimate.526#[doc(hidden)]527pub const fn nte_duration_ns(&self) -> i64 {528self.months * (31 * 24 + 1) * 3600 * NANOSECONDS529+ self.weeks * NTE_NS_WEEK530+ self.days * NTE_NS_DAY531+ self.nsecs532}533534#[doc(hidden)]535pub const fn nte_duration_us(&self) -> i64 {536self.months * (31 * 24 + 1) * 3600 * MICROSECONDS537+ self.weeks * NTE_NS_WEEK / 1000538+ self.days * NTE_NS_DAY / 1000539+ self.nsecs / 1000540}541542#[doc(hidden)]543pub const fn nte_duration_ms(&self) -> i64 {544self.months * (31 * 24 + 1) * 3600 * MILLISECONDS545+ self.weeks * NTE_NS_WEEK / 1_000_000546+ self.days * NTE_NS_DAY / 1_000_000547+ self.nsecs / 1_000_000548}549550#[doc(hidden)]551fn add_month(ts: NaiveDateTime, n_months: i64, negative: bool) -> NaiveDateTime {552let mut months = n_months;553if negative {554months = -months;555}556557// Retrieve the current date and increment the values558// based on the number of months559let mut year = ts.year();560let mut month = ts.month() as i32;561let mut day = ts.day();562year += (months / 12) as i32;563month += (months % 12) as i32;564565// if the month overflowed or underflowed, adjust the year566// accordingly. Because we add the modulo for the months567// the year will only adjust by one568if month > 12 {569year += 1;570month -= 12;571} else if month <= 0 {572year -= 1;573month += 12;574}575576// Normalize the day if we are past the end of the month.577let last_day_of_month =578DAYS_PER_MONTH[is_leap_year(year) as usize][(month - 1) as usize] as u32;579580if day > last_day_of_month {581day = last_day_of_month582}583584// Retrieve the original time and construct a data585// with the new year, month and day586let hour = ts.hour();587let minute = ts.minute();588let sec = ts.second();589let nsec = ts.nanosecond();590new_datetime(year, month as u32, day, hour, minute, sec, nsec).expect(591"Expected valid datetime, please open an issue at https://github.com/pola-rs/polars/issues"592)593}594595/// Localize result to given time zone, respecting RFC5545 to deal with non-existent or596/// ambiguous results.597///598/// For ambiguous and non-existent results, we preserve the DST fold of the original datetime.599///600/// * `original_dt_utc` - original datetime converted to UTC. E.g. if the601/// original datetime was 2022-11-06 01:30:00 CST, then this would602/// be 2022-11-06 07:30:00.603/// * `result_dt_local` - result, without time zone.604/// * `tz` - time zone.605#[cfg(feature = "timezones")]606fn localize_result_rfc_5545(607&self,608original_dt_utc: NaiveDateTime,609result_dt_local: NaiveDateTime,610tz: &Tz,611) -> PolarsResult<NaiveDateTime> {612let result_localized = tz.from_local_datetime(&result_dt_local);613match result_localized {614LocalResult::Single(result) => Ok(result.naive_utc()),615LocalResult::Ambiguous(result_earliest, result_latest) => {616let original_localized = tz.from_utc_datetime(&original_dt_utc);617let original_dst_offset = original_localized.offset().dst_offset();618if result_earliest.offset().dst_offset() == original_dst_offset {619return Ok(result_earliest.naive_utc());620}621if result_latest.offset().dst_offset() == original_dst_offset {622return Ok(result_latest.naive_utc());623}624polars_bail!(ComputeError: "Could not localize datetime '{}' to time zone '{}'", result_dt_local, tz);625},626LocalResult::None => {627let original_localized = tz.from_utc_datetime(&original_dt_utc);628let original_dst_offset = original_localized.offset().dst_offset();629let shifted: NaiveDateTime;630if original_dst_offset.num_minutes() != 0 {631shifted = result_dt_local.add(original_dst_offset);632} else if let Some(next_hour) = tz633.from_local_datetime(&result_dt_local.add(TimeDelta::hours(1)))634.earliest()635{636// Try shifting forwards to get the DST offset of the would-be-result.637let result_dst_offset = next_hour.offset().dst_offset();638shifted = result_dt_local.add(-result_dst_offset);639} else {640polars_bail!(ComputeError: "Could not localize datetime '{}' to time zone '{}'", result_dt_local, tz);641}642Ok(643try_localize_datetime(shifted, tz, Ambiguous::Raise, NonExistent::Raise)?644.expect("we didn't use Ambiguous::Null or NonExistent::Null"),645)646},647}648}649650fn truncate_subweekly<G, J>(651&self,652t: i64,653tz: Option<&Tz>,654duration: i64,655_timestamp_to_datetime: G,656_datetime_to_timestamp: J,657) -> PolarsResult<i64>658where659G: Fn(i64) -> NaiveDateTime,660J: Fn(NaiveDateTime) -> i64,661{662match tz {663#[cfg(feature = "timezones")]664// for UTC, use fastpath below (same as naive)665Some(tz) if tz != &chrono_tz::UTC => {666let original_dt_utc = _timestamp_to_datetime(t);667let original_dt_local = unlocalize_datetime(original_dt_utc, tz);668let t = _datetime_to_timestamp(original_dt_local);669let mut remainder = t % duration;670if remainder < 0 {671remainder += duration672}673let result_timestamp = t - remainder;674let result_dt_local = _timestamp_to_datetime(result_timestamp);675let result_dt_utc =676self.localize_result_rfc_5545(original_dt_utc, result_dt_local, tz)?;677Ok(_datetime_to_timestamp(result_dt_utc))678},679_ => {680let mut remainder = t % duration;681if remainder < 0 {682remainder += duration683}684Ok(t - remainder)685},686}687}688689fn truncate_weekly<G, J>(690&self,691t: i64,692tz: Option<&Tz>,693_timestamp_to_datetime: G,694_datetime_to_timestamp: J,695daily_duration: i64,696) -> PolarsResult<i64>697where698G: Fn(i64) -> NaiveDateTime,699J: Fn(NaiveDateTime) -> i64,700{701let _original_dt_utc: Option<NaiveDateTime>;702let _original_dt_local: Option<NaiveDateTime>;703let t = match tz {704#[cfg(feature = "timezones")]705// for UTC, use fastpath below (same as naive)706Some(tz) if tz != &chrono_tz::UTC => {707_original_dt_utc = Some(_timestamp_to_datetime(t));708_original_dt_local = Some(unlocalize_datetime(_original_dt_utc.unwrap(), tz));709_datetime_to_timestamp(_original_dt_local.unwrap())710},711_ => {712_original_dt_utc = None;713_original_dt_local = None;714t715},716};717// If we did718// t - (t % (7 * self.weeks * daily_duration))719// then the timestamp would get truncated to the previous Thursday,720// because 1970-01-01 (timestamp 0) is a Thursday.721// So, we adjust by 4 days to get to Monday.722let mut remainder = (t - 4 * daily_duration) % (7 * self.weeks * daily_duration);723if remainder < 0 {724remainder += 7 * self.weeks * daily_duration725}726let result_t_local = t - remainder;727match tz {728#[cfg(feature = "timezones")]729// for UTC, use fastpath below (same as naive)730Some(tz) if tz != &chrono_tz::UTC => {731let result_dt_local = _timestamp_to_datetime(result_t_local);732let result_dt_utc =733self.localize_result_rfc_5545(_original_dt_utc.unwrap(), result_dt_local, tz)?;734Ok(_datetime_to_timestamp(result_dt_utc))735},736_ => Ok(result_t_local),737}738}739fn truncate_monthly<G, J>(740&self,741t: i64,742tz: Option<&Tz>,743timestamp_to_datetime: G,744datetime_to_timestamp: J,745daily_duration: i64,746) -> PolarsResult<i64>747where748G: Fn(i64) -> NaiveDateTime,749J: Fn(NaiveDateTime) -> i64,750{751let original_dt_utc;752let original_dt_local;753let t = match tz {754#[cfg(feature = "timezones")]755// for UTC, use fastpath below (same as naive)756Some(tz) if tz != &chrono_tz::UTC => {757original_dt_utc = timestamp_to_datetime(t);758original_dt_local = unlocalize_datetime(original_dt_utc, tz);759datetime_to_timestamp(original_dt_local)760},761_ => {762original_dt_utc = timestamp_to_datetime(t);763original_dt_local = original_dt_utc;764datetime_to_timestamp(original_dt_local)765},766};767768// Remove the time of day from the timestamp769// e.g. 2020-01-01 12:34:56 -> 2020-01-01 00:00:00770let mut remainder_time = t % daily_duration;771if remainder_time < 0 {772remainder_time += daily_duration773}774let t = t - remainder_time;775776// Calculate how many months we need to subtract...777let (mut year, mut month) = (778original_dt_local.year() as i64,779original_dt_local.month() as i64,780);781let total = ((year - 1970) * 12) + (month - 1);782let mut remainder_months = total % self.months;783if remainder_months < 0 {784remainder_months += self.months785}786787// ...and translate that to how many days we need to subtract.788let mut _is_leap_year = is_leap_year(year as i32);789let mut remainder_days = (original_dt_local.day() - 1) as i64;790while remainder_months > 12 {791let prev_year_is_leap_year = is_leap_year((year - 1) as i32);792let add_extra_day =793(_is_leap_year && month > 2) || (prev_year_is_leap_year && month <= 2);794remainder_days += 365 + add_extra_day as i64;795remainder_months -= 12;796year -= 1;797_is_leap_year = prev_year_is_leap_year;798}799while remainder_months > 0 {800month -= 1;801if month == 0 {802year -= 1;803_is_leap_year = is_leap_year(year as i32);804month = 12;805}806remainder_days += DAYS_PER_MONTH[_is_leap_year as usize][(month - 1) as usize];807remainder_months -= 1;808}809810match tz {811#[cfg(feature = "timezones")]812// for UTC, use fastpath below (same as naive)813Some(tz) if tz != &chrono_tz::UTC => {814let result_dt_local = timestamp_to_datetime(t - remainder_days * daily_duration);815let result_dt_utc =816self.localize_result_rfc_5545(original_dt_utc, result_dt_local, tz)?;817Ok(datetime_to_timestamp(result_dt_utc))818},819_ => Ok(t - remainder_days * daily_duration),820}821}822823#[inline]824pub fn truncate_impl<F, G, J>(825&self,826t: i64,827tz: Option<&Tz>,828nsecs_to_unit: F,829timestamp_to_datetime: G,830datetime_to_timestamp: J,831) -> PolarsResult<i64>832where833F: Fn(i64) -> i64,834G: Fn(i64) -> NaiveDateTime,835J: Fn(NaiveDateTime) -> i64,836{837match (self.months, self.weeks, self.days, self.nsecs) {838(0, 0, 0, 0) => polars_bail!(ComputeError: "duration cannot be zero"),839// truncate by ns/us/ms840(0, 0, 0, _) => {841let duration = nsecs_to_unit(self.nsecs);842if duration == 0 {843return Ok(t);844}845self.truncate_subweekly(846t,847tz,848duration,849timestamp_to_datetime,850datetime_to_timestamp,851)852},853// truncate by days854(0, 0, _, 0) => {855let duration = self.days * nsecs_to_unit(NS_DAY);856self.truncate_subweekly(857t,858tz,859duration,860timestamp_to_datetime,861datetime_to_timestamp,862)863},864// truncate by weeks865(0, _, 0, 0) => {866let duration = nsecs_to_unit(NS_DAY);867self.truncate_weekly(868t,869tz,870timestamp_to_datetime,871datetime_to_timestamp,872duration,873)874},875// truncate by months876(_, 0, 0, 0) => {877let duration = nsecs_to_unit(NS_DAY);878self.truncate_monthly(879t,880tz,881timestamp_to_datetime,882datetime_to_timestamp,883duration,884)885},886_ => {887polars_bail!(ComputeError: "cannot mix month, week, day, and sub-daily units for this operation")888},889}890}891892// Truncate the given ns timestamp by the window boundary.893#[inline]894pub fn truncate_ns(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {895self.truncate_impl(896t,897tz,898|nsecs| nsecs,899timestamp_ns_to_datetime,900datetime_to_timestamp_ns,901)902}903904// Truncate the given ns timestamp by the window boundary.905#[inline]906pub fn truncate_us(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {907self.truncate_impl(908t,909tz,910|nsecs| nsecs / 1000,911timestamp_us_to_datetime,912datetime_to_timestamp_us,913)914}915916// Truncate the given ms timestamp by the window boundary.917#[inline]918pub fn truncate_ms(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {919self.truncate_impl(920t,921tz,922|nsecs| nsecs / 1_000_000,923timestamp_ms_to_datetime,924datetime_to_timestamp_ms,925)926}927928fn add_impl_month_week_or_day<F, G, J>(929&self,930mut t: i64,931tz: Option<&Tz>,932nsecs_to_unit: F,933timestamp_to_datetime: G,934datetime_to_timestamp: J,935) -> PolarsResult<i64>936where937F: Fn(i64) -> i64,938G: Fn(i64) -> NaiveDateTime,939J: Fn(NaiveDateTime) -> i64,940{941let d = self;942943if d.months > 0 {944t = match tz {945#[cfg(feature = "timezones")]946// for UTC, use fastpath below (same as naive)947Some(tz) if tz != &chrono_tz::UTC => {948let original_dt_utc = timestamp_to_datetime(t);949let original_dt_local = unlocalize_datetime(original_dt_utc, tz);950let result_dt_local = Self::add_month(original_dt_local, d.months, d.negative);951datetime_to_timestamp(self.localize_result_rfc_5545(952original_dt_utc,953result_dt_local,954tz,955)?)956},957_ => datetime_to_timestamp(Self::add_month(958timestamp_to_datetime(t),959d.months,960d.negative,961)),962};963}964965if d.weeks > 0 {966let t_weeks = nsecs_to_unit(NS_WEEK) * self.weeks;967t = match tz {968#[cfg(feature = "timezones")]969// for UTC, use fastpath below (same as naive)970Some(tz) if tz != &chrono_tz::UTC => {971let original_dt_utc = timestamp_to_datetime(t);972let original_dt_local = unlocalize_datetime(original_dt_utc, tz);973let mut result_timestamp_local = datetime_to_timestamp(original_dt_local);974result_timestamp_local += if d.negative { -t_weeks } else { t_weeks };975let result_dt_local = timestamp_to_datetime(result_timestamp_local);976datetime_to_timestamp(self.localize_result_rfc_5545(977original_dt_utc,978result_dt_local,979tz,980)?)981},982_ => {983if d.negative {984t - t_weeks985} else {986t + t_weeks987}988},989};990}991992if d.days > 0 {993let t_days = nsecs_to_unit(NS_DAY) * self.days;994t = match tz {995#[cfg(feature = "timezones")]996// for UTC, use fastpath below (same as naive)997Some(tz) if tz != &chrono_tz::UTC => {998let original_dt_utc = timestamp_to_datetime(t);999let original_dt_local = unlocalize_datetime(original_dt_utc, tz);1000t = datetime_to_timestamp(original_dt_local);1001t += if d.negative { -t_days } else { t_days };1002let result_dt_local = timestamp_to_datetime(t);1003let result_dt_utc =1004self.localize_result_rfc_5545(original_dt_utc, result_dt_local, tz)?;1005datetime_to_timestamp(result_dt_utc)1006},1007_ => {1008if d.negative {1009t - t_days1010} else {1011t + t_days1012}1013},1014};1015}10161017Ok(t)1018}10191020pub fn add_ns(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {1021let d = self;1022let new_t = self.add_impl_month_week_or_day(1023t,1024tz,1025|nsecs| nsecs,1026timestamp_ns_to_datetime,1027datetime_to_timestamp_ns,1028);1029let nsecs = if d.negative { -d.nsecs } else { d.nsecs };1030Ok(new_t? + nsecs)1031}10321033pub fn add_us(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {1034let d = self;1035let new_t = self.add_impl_month_week_or_day(1036t,1037tz,1038|nsecs| nsecs / 1000,1039timestamp_us_to_datetime,1040datetime_to_timestamp_us,1041);1042let nsecs = if d.negative { -d.nsecs } else { d.nsecs };1043Ok(new_t? + nsecs / 1_000)1044}10451046pub fn add_ms(&self, t: i64, tz: Option<&Tz>) -> PolarsResult<i64> {1047let d = self;1048let new_t = self.add_impl_month_week_or_day(1049t,1050tz,1051|nsecs| nsecs / 1_000_000,1052timestamp_ms_to_datetime,1053datetime_to_timestamp_ms,1054);1055let nsecs = if d.negative { -d.nsecs } else { d.nsecs };1056Ok(new_t? + nsecs / 1_000_000)1057}1058}10591060impl Mul<i64> for Duration {1061type Output = Self;10621063fn mul(mut self, mut rhs: i64) -> Self {1064if rhs < 0 {1065rhs = -rhs;1066self.negative = !self.negative1067}1068self.months *= rhs;1069self.weeks *= rhs;1070self.days *= rhs;1071self.nsecs *= rhs;1072self1073}1074}10751076fn new_datetime(1077year: i32,1078month: u32,1079days: u32,1080hour: u32,1081min: u32,1082sec: u32,1083nano: u32,1084) -> Option<NaiveDateTime> {1085let date = NaiveDate::from_ymd_opt(year, month, days)?;1086let time = NaiveTime::from_hms_nano_opt(hour, min, sec, nano)?;1087Some(NaiveDateTime::new(date, time))1088}10891090pub fn ensure_is_constant_duration(1091duration: Duration,1092time_zone: Option<&TimeZone>,1093variable_name: &str,1094) -> PolarsResult<()> {1095polars_ensure!(duration.is_constant_duration(time_zone),1096InvalidOperation: "expected `{}` to be a constant duration \1097(i.e. one independent of differing month durations or of daylight savings time), got {}.\n\1098\n\1099You may want to try:\n\1100- using `'730h'` instead of `'1mo'`\n\1101- using `'24h'` instead of `'1d'` if your series is time-zone-aware", variable_name, duration);1102Ok(())1103}11041105pub fn ensure_duration_matches_dtype(1106duration: Duration,1107dtype: &DataType,1108variable_name: &str,1109) -> PolarsResult<()> {1110match dtype {1111DataType::Int64 | DataType::UInt64 | DataType::Int32 | DataType::UInt32 => {1112polars_ensure!(duration.parsed_int || duration.is_zero(),1113InvalidOperation: "`{}` duration must be a parsed integer (i.e. use '2i', not '2d') when working with a numeric column", variable_name);1114},1115DataType::Datetime(_, _) | DataType::Date | DataType::Duration(_) | DataType::Time => {1116polars_ensure!(!duration.parsed_int,1117InvalidOperation: "`{}` duration may not be a parsed integer (i.e. use '2d', not '2i') when working with a temporal column", variable_name);1118},1119_ => {1120polars_bail!(InvalidOperation: "unsupported data type: {} for temporal/index column, expected UInt64, UInt32, Int64, Int32, Datetime, Date, Duration, or Time", dtype)1121},1122}1123Ok(())1124}11251126#[cfg(test)]1127mod test {1128use super::*;11291130#[test]1131fn test_parse() {1132let out = Duration::parse("1ns");1133assert_eq!(out.nsecs, 1);1134let out = Duration::parse("1ns1ms");1135assert_eq!(out.nsecs, NS_MILLISECOND + 1);1136let out = Duration::parse("123ns40ms");1137assert_eq!(out.nsecs, 40 * NS_MILLISECOND + 123);1138let out = Duration::parse("123ns40ms1w");1139assert_eq!(out.nsecs, 40 * NS_MILLISECOND + 123);1140assert_eq!(out.duration_ns(), 40 * NS_MILLISECOND + 123 + NS_WEEK);1141let out = Duration::parse("-123ns40ms1w");1142assert!(out.negative);1143let out = Duration::parse("5w");1144assert_eq!(out.weeks(), 5);1145}11461147#[test]1148fn test_parse_interval() {1149let d = Duration::try_parse_interval("3 DAYS").unwrap();1150assert_eq!(d.days(), 3);11511152let d = Duration::try_parse_interval("1 year, 2 months, 1 week").unwrap();1153assert_eq!(d.months(), 14);1154assert_eq!(d.weeks(), 1);11551156let d = Duration::try_parse_interval("100ms 100us").unwrap();1157assert_eq!(d.duration_us(), 100_100);1158}11591160#[test]1161fn test_add_ns() {1162let t = 1;1163let seven_days = Duration::parse("7d");1164let one_week = Duration::parse("1w");11651166// add_ns can only error if a time zone is passed, so it's1167// safe to unwrap here1168assert_eq!(1169seven_days.add_ns(t, None).unwrap(),1170one_week.add_ns(t, None).unwrap()1171);11721173let seven_days_negative = Duration::parse("-7d");1174let one_week_negative = Duration::parse("-1w");11751176// add_ns can only error if a time zone is passed, so it's1177// safe to unwrap here1178assert_eq!(1179seven_days_negative.add_ns(t, None).unwrap(),1180one_week_negative.add_ns(t, None).unwrap()1181);1182}11831184#[test]1185fn test_display() {1186let duration = Duration::parse("1h");1187let expected = "3600s";1188assert_eq!(format!("{duration}"), expected);1189let duration = Duration::parse("1h5ns");1190let expected = "3600000000005ns";1191assert_eq!(format!("{duration}"), expected);1192let duration = Duration::parse("1h5000ns");1193let expected = "3600000005us";1194assert_eq!(format!("{duration}"), expected);1195let duration = Duration::parse("3mo");1196let expected = "3mo";1197assert_eq!(format!("{duration}"), expected);1198let duration = Duration::parse_interval("4 weeks");1199let expected = "4w";1200assert_eq!(format!("{duration}"), expected);1201}1202}120312041205