Skip to content

Commit

Permalink
[Fix #286] %m+-% correctly handles dHMS period components
Browse files Browse the repository at this point in the history
  New public function `add_with_rollback`.
  • Loading branch information
vspinu committed Apr 28, 2015
1 parent 9cf5ab7 commit 00ff073
Show file tree
Hide file tree
Showing 9 changed files with 141 additions and 77 deletions.
1 change: 1 addition & 0 deletions NAMESPACE
Expand Up @@ -77,6 +77,7 @@ export("wday<-")
export("week<-")
export("yday<-")
export("year<-")
export(add_with_rollback)
export(am)
export(as.difftime)
export(as.duration)
Expand Down
4 changes: 2 additions & 2 deletions R/intervals.r
Expand Up @@ -609,10 +609,10 @@ setMethod("time_length", signature("Interval"), function(x, unit = "second") {
periods <- as.period(x, unit = unit)
int_part <- slot(periods, unit)

prev_aniv <- .month_plus(
prev_aniv <- add_with_rollback(
int_start(x), (int_part * period(1, units = unit)),
roll_to_first = TRUE, preserve_hms = FALSE)
next_aniv <- .month_plus(
next_aniv <- add_with_rollback(
int_start(x), ((int_part + ifelse(x@.Data < 0, -1, 1)) * period(1, units = unit)),
roll_to_first = TRUE, preserve_hms = FALSE)

Expand Down
87 changes: 56 additions & 31 deletions R/ops-%m+%.r
Expand Up @@ -11,21 +11,25 @@ NULL

#' Add and subtract months to a date without exceeding the last day of the new month
#'
#' Adding months frustrates basic arithmetic because consecutive months have different lengths.
#' With other elements, it is helpful for arithmetic to perform automatic roll over. For
#' example, 12:00:00 + 61 seconds becomes 12:01:01. However, people often prefer that this behavior
#' NOT occur with months. For example, we sometimes want January 31 + 1 month = February 28 and not
#' March 3. %m+% performs this type of arithmetic. Date %m+% months(n) always returns a date in the
#' nth month after Date. If the new date would usually spill over into the n + 1th month, %m+% will
#' return the last day of the nth month. Date %m-% months(n) always returns a date in the
#' nth month before Date.
#' Adding months frustrates basic arithmetic because consecutive months have
#' different lengths. With other elements, it is helpful for arithmetic to
#' perform automatic roll over. For example, 12:00:00 + 61 seconds becomes
#' 12:01:01. However, people often prefer that this behavior NOT occur with
#' months. For example, we sometimes want January 31 + 1 month = February 28 and
#' not March 3. \%m+\% performs this type of arithmetic. Date \%m+\% months(n)
#' always returns a date in the nth month after Date. If the new date would
#' usually spill over into the n + 1th month, \%m+\% will return the last day of
#' the nth month (\code{\link{rollback}}. Date \%m-\% months(n) always returns a
#' date in the nth month before Date.
#'
#' %m+% and %m-% do not handle periods less than a month. These must be added separately with traditional
#' arithmetic. %m+% and %m-% should be used with caution as they are not one-to-one operations and
#' results for either will be sensitive to the order of operations.
#'
#' \%m+\% and \%m-\% handle periods with components less than a month by first
#' adding/substracting months and then performing usual arithmetics with smaller
#' units.
#'
#' @export
#' \%m+\% and \%m-\% should be used with caution as they are not one-to-one
#' operations and results for either will be sensitive to the order of
#' operations.
#'
#' @rdname mplus
#' @usage e1 \%m+\% e2
#' @aliases m+ %m+% m- %m-% %m+%,ANY,ANY-method %m-%,ANY,ANY-method %m+%,Period,ANY-method %m+%,ANY,Period-method %m-%,Period,ANY-method %m-%,ANY,Period-method %m+%,Duration,ANY-method %m+%,ANY,Duration-method %m-%,Duration,ANY-method %m-%,ANY,Duration-method %m+%,Interval,ANY-method %m+%,ANY,Interval-method %m-%,Interval,ANY-method %m-%,ANY,Interval-method
Expand All @@ -50,18 +54,19 @@ NULL
#' leap %m+% years(-1)
#' leap %m-% years(1)
#' # "2011-02-28 UTC"
#' @export
"%m+%" <- function(e1,e2) standardGeneric("%m+%")

#' @export
setGeneric("%m+%")

#' @export
setMethod("%m+%", signature(e2 = "Period"),
function(e1, e2) .month_plus(e1, e2))
function(e1, e2) add_with_rollback(e1, e2))

#' @export
setMethod("%m+%", signature(e1 = "Period"),
function(e1, e2) .month_plus(e2, e1))
function(e1, e2) add_with_rollback(e2, e1))

#' @export
setMethod("%m+%", signature(e2 = "ANY"),
Expand All @@ -76,29 +81,47 @@ setGeneric("%m-%")

#' @export
setMethod("%m-%", signature(e2 = "Period"),
function(e1, e2) .month_plus(e1, -e2))
function(e1, e2) add_with_rollback(e1, -e2))

#' @export
setMethod("%m-%", signature(e1 = "Period"),
function(e1, e2) .month_plus(e2, -e1))
function(e1, e2) add_with_rollback(e2, -e1))

#' @export
setMethod("%m-%", signature(e2 = "ANY"),
function(e1, e2)
stop("%m-% only handles Period objects with month or year units"))

.month_plus <- function(e1, e2, roll_to_first = FALSE, preserve_hms = TRUE) {
if (any(c(e2@.Data, e2@minute, e2@hour, e2@day) != 0))
stop("%m+% only handles month and years. Add other periods separately with '+'")
#' \code{add_with_rollback} provides additional functionality to \%m+\% and
#' \%m-\%. It allows rollback to first day of the month instead of the last day
#' of the previous month and controls whether HMS component of the end date is
#' preserved or not.
#' @rdname mplus
#' @param roll_to_first rollback to the first day of the month instead of the
#' last day of the previous month (passed to \code{\link{rollback}})
#' @param preserve_hms retains the same hour, minute, and second information? If
#' FALSE, the new date will be at 00:00:00 (passed to \code{\link{rollback}})
#' @export
add_with_rollback <- function(e1, e2, roll_to_first = FALSE, preserve_hms = TRUE) {

if (any(e2@year != 0)) e2 <- months(12 * e2@year + e2@month)
HMS <- any(e2@.Data != 0) || any(e2@minute != 0) || any(e2@hour != 0) || any(e2@day != 0)

if (any(e2@year != 0)) {
e2$month <- 12 * e2@year + e2@month
e2$year <- 0L
}

new <- .quick_month_add(e1, e2@month)
roll <- day(new) < day(e1)
new[roll] <- rollback(new[roll], roll_to_first = roll_to_first, preserve_hms = preserve_hms)
new
}

if(HMS) {
e2$month <- 0L
new + e2
} else {
new
}
}

.quick_month_add <- function(object, mval) {
tzs <- tz(object)
Expand All @@ -111,17 +134,19 @@ setMethod("%m-%", signature(e2 = "ANY"),

#' Roll back date to last day of previous month
#'
#' rollback changes a date to the last day of the previous month or to the first day of the month.
#' Optionally, the new date can retain the same hour, minute, and second information.
#' rollback changes a date to the last day of the previous month or to the first
#' day of the month. Optionally, the new date can retain the same hour, minute,
#' and second information.
#'
#' @export
#' @param dates A POSIXct, POSIXlt or Date class object.
#' @param roll_to_first Rollback to the first day of the month instead of the last day of the
#' previous month
#' @param preserve_hms Retains the same hour, minute, and second information? If FALSE, the new
#' date will be at 00:00:00.
#' @return A date-time object of class POSIXlt, POSIXct or Date, whose day has been adjusted to the
#' last day of the previous month, or to the first day of the month.
#' @param roll_to_first Rollback to the first day of the month instead of the
#' last day of the previous month
#' @param preserve_hms Retains the same hour, minute, and second information? If
#' FALSE, the new date will be at 00:00:00.
#' @return A date-time object of class POSIXlt, POSIXct or Date, whose day has
#' been adjusted to the last day of the previous month, or to the first day of
#' the month.
#' @examples
#' date <- ymd("2010-03-03")
#' # "2010-03-03 UTC"
Expand Down
2 changes: 1 addition & 1 deletion R/ops-division.r
Expand Up @@ -27,7 +27,7 @@ adjust <- function(est, int, per) {
start <- int_start(int)
end <- int_end(int)

while(any(which <- (start + est * per < end)))
while(any(which <- (start + est * per < end)))
est[which] <- est[which] + 1

while(any(which <- (start + est * per > end)))
Expand Down
26 changes: 13 additions & 13 deletions R/periods.r
Expand Up @@ -81,19 +81,19 @@ check_period <- function(object){
#' \code{\link{Duration-class}} for an alternative way to measure timespans that
#' allows precise comparisons between timespans.
#'
#' The logic that guides arithmetic with periods can be unintuitive. Starting
#' with version 1.3.0, lubridate enforces the reversible property of arithmetic
#' (e.g. a date + period - period = date)
#' by returning an NA if you create an implausible date by adding periods with
#' months or years units to a date. For example,
#' adding one month to January 31st, 2013 results in February 31st, 2013, which
#' is not a real date. lubridate users have argued in the past that February
#' 31st, 2013 should be rolled over to March 3rd, 2013 or rolled back to February
#' 28, 2013. However, each of these corrections would destroy the reversibility
#' of addition (Mar 3 - one month == Feb 3 != Jan 31, Feb 28 - one month == Jan
#' 28 != Jan 31). If you would like to add and subtract months in a way that rolls
#' the results back to the last day of a month (when appropriate) use the special
#' operators, \code{\link{\%m+\%}} and \code{\link{\%m-\%}}.
#' The logic that guides arithmetic with periods can be unintuitive. Starting
#' with version 1.3.0, lubridate enforces the reversible property of arithmetic
#' (e.g. a date + period - period = date) by returning an NA if you create an
#' implausible date by adding periods with months or years units to a date. For
#' example, adding one month to January 31st, 2013 results in February 31st,
#' 2013, which is not a real date. lubridate users have argued in the past that
#' February 31st, 2013 should be rolled over to March 3rd, 2013 or rolled back
#' to February 28, 2013. However, each of these corrections would destroy the
#' reversibility of addition (Mar 3 - one month == Feb 3 != Jan 31, Feb 28 - one
#' month == Jan 28 != Jan 31). If you would like to add and subtract months in a
#' way that rolls the results back to the last day of a month (when appropriate)
#' use the special operators, \code{\link{\%m+\%}}, \code{\link{\%m-\%}} or a
#' bit more flexible \code{\link{add_with_rollback}}.
#'
#' Period class objects have six slots. 1) .Data, a numeric object. The
#' apparent amount of seconds to add to the period. 2) minute, a numeric object.
Expand Down
22 changes: 11 additions & 11 deletions man/Period-class.Rd
Expand Up @@ -149,17 +149,17 @@ allows precise comparisons between timespans.

The logic that guides arithmetic with periods can be unintuitive. Starting
with version 1.3.0, lubridate enforces the reversible property of arithmetic
(e.g. a date + period - period = date)
by returning an NA if you create an implausible date by adding periods with
months or years units to a date. For example,
adding one month to January 31st, 2013 results in February 31st, 2013, which
is not a real date. lubridate users have argued in the past that February
31st, 2013 should be rolled over to March 3rd, 2013 or rolled back to February
28, 2013. However, each of these corrections would destroy the reversibility
of addition (Mar 3 - one month == Feb 3 != Jan 31, Feb 28 - one month == Jan
28 != Jan 31). If you would like to add and subtract months in a way that rolls
the results back to the last day of a month (when appropriate) use the special
operators, \code{\link{\%m+\%}} and \code{\link{\%m-\%}}.
(e.g. a date + period - period = date) by returning an NA if you create an
implausible date by adding periods with months or years units to a date. For
example, adding one month to January 31st, 2013 results in February 31st,
2013, which is not a real date. lubridate users have argued in the past that
February 31st, 2013 should be rolled over to March 3rd, 2013 or rolled back
to February 28, 2013. However, each of these corrections would destroy the
reversibility of addition (Mar 3 - one month == Feb 3 != Jan 31, Feb 28 - one
month == Jan 28 != Jan 31). If you would like to add and subtract months in a
way that rolls the results back to the last day of a month (when appropriate)
use the special operators, \code{\link{\%m+\%}}, \code{\link{\%m-\%}} or a
bit more flexible \code{\link{add_with_rollback}}.

Period class objects have six slots. 1) .Data, a numeric object. The
apparent amount of seconds to add to the period. 2) minute, a numeric object.
Expand Down
42 changes: 31 additions & 11 deletions man/mplus.Rd
Expand Up @@ -17,11 +17,14 @@
\alias{\%m-\%,Duration,ANY-method}
\alias{\%m-\%,Interval,ANY-method}
\alias{\%m-\%,Period,ANY-method}
\alias{add_with_rollback}
\alias{m+}
\alias{m-}
\title{Add and subtract months to a date without exceeding the last day of the new month}
\usage{
e1 \%m+\% e2

add_with_rollback(e1, e2, roll_to_first = FALSE, preserve_hms = TRUE)
}
\arguments{
\item{e1}{A period or a date-time object of class \code{\link{POSIXlt}}, \code{\link{POSIXct}}
Expand All @@ -30,24 +33,41 @@ or \code{\link{Date}}.}
\item{e2}{A period or a date-time object of class \code{\link{POSIXlt}}, \code{\link{POSIXct}}
or \code{\link{Date}}. Note that one of e1 and e2 must be a period and the other a
date-time object.}

\item{roll_to_first}{rollback to the first day of the month instead of the
last day of the previous month (passed to \code{\link{rollback}})}

\item{preserve_hms}{retains the same hour, minute, and second information? If
FALSE, the new date will be at 00:00:00 (passed to \code{\link{rollback}})}
}
\value{
A date-time object of class POSIXlt, POSIXct or Date
}
\description{
Adding months frustrates basic arithmetic because consecutive months have different lengths.
With other elements, it is helpful for arithmetic to perform automatic roll over. For
example, 12:00:00 + 61 seconds becomes 12:01:01. However, people often prefer that this behavior
NOT occur with months. For example, we sometimes want January 31 + 1 month = February 28 and not
March 3. %m+% performs this type of arithmetic. Date %m+% months(n) always returns a date in the
nth month after Date. If the new date would usually spill over into the n + 1th month, %m+% will
return the last day of the nth month. Date %m-% months(n) always returns a date in the
nth month before Date.
Adding months frustrates basic arithmetic because consecutive months have
different lengths. With other elements, it is helpful for arithmetic to
perform automatic roll over. For example, 12:00:00 + 61 seconds becomes
12:01:01. However, people often prefer that this behavior NOT occur with
months. For example, we sometimes want January 31 + 1 month = February 28 and
not March 3. \%m+\% performs this type of arithmetic. Date \%m+\% months(n)
always returns a date in the nth month after Date. If the new date would
usually spill over into the n + 1th month, \%m+\% will return the last day of
the nth month (\code{\link{rollback}}. Date \%m-\% months(n) always returns a
date in the nth month before Date.

\code{add_with_rollback} provides additional functionality to \%m+\% and
\%m-\%. It allows rollback to first day of the month instead of the last day
of the previous month and controls whether HMS component of the end date is
preserved or not.
}
\details{
%m+% and %m-% do not handle periods less than a month. These must be added separately with traditional
arithmetic. %m+% and %m-% should be used with caution as they are not one-to-one operations and
results for either will be sensitive to the order of operations.
\%m+\% and \%m-\% handle periods with components less than a month by first
adding/substracting months and then performing usual arithmetics with smaller
units.

\%m+\% and \%m-\% should be used with caution as they are not one-to-one
operations and results for either will be sensitive to the order of
operations.
}
\examples{
jan <- ymd_hms("2010-01-31 03:04:05")
Expand Down
18 changes: 10 additions & 8 deletions man/rollback.Rd
Expand Up @@ -9,19 +9,21 @@ rollback(dates, roll_to_first = FALSE, preserve_hms = TRUE)
\arguments{
\item{dates}{A POSIXct, POSIXlt or Date class object.}

\item{roll_to_first}{Rollback to the first day of the month instead of the last day of the
previous month}
\item{roll_to_first}{Rollback to the first day of the month instead of the
last day of the previous month}

\item{preserve_hms}{Retains the same hour, minute, and second information? If FALSE, the new
date will be at 00:00:00.}
\item{preserve_hms}{Retains the same hour, minute, and second information? If
FALSE, the new date will be at 00:00:00.}
}
\value{
A date-time object of class POSIXlt, POSIXct or Date, whose day has been adjusted to the
last day of the previous month, or to the first day of the month.
A date-time object of class POSIXlt, POSIXct or Date, whose day has
been adjusted to the last day of the previous month, or to the first day of
the month.
}
\description{
rollback changes a date to the last day of the previous month or to the first day of the month.
Optionally, the new date can retain the same hour, minute, and second information.
rollback changes a date to the last day of the previous month or to the first
day of the month. Optionally, the new date can retain the same hour, minute,
and second information.
}
\examples{
date <- ymd("2010-03-03")
Expand Down
16 changes: 16 additions & 0 deletions tests/testthat/test-ops-addition.R
Expand Up @@ -251,6 +251,22 @@ test_that("%m+% correctly adds years without rollover",{
expect_equal(leap %m+% new_period(years = 1, months = 1), next2)
})

test_that("%m+% correctly adds years, months, days and HMS (#286)",{
date <- ymd("2012-02-29")
per_all <- new_period(years = 1, months = 1, days = 2, hours = 1, minutes = 20, seconds = 30)
per_major <- new_period(years = 1, months = 1)
per_minor <- new_period(days = 2, hours = 1, minutes = 20, seconds = 30)
expect_equal(date %m+% per_all,
date %m+% per_major + per_minor)

date <- ymd("2012-03-31")
per_all <- new_period(months = 3, days = 30, hours = 1, minutes = 20, seconds = 30)
per_major <- new_period(months = 3)
per_minor <- new_period(days = 30, hours = 1, minutes = 20, seconds = 30)
expect_equal(date %m+% per_all,
date %m+% per_major + per_minor)
})

test_that("%m+% correctly adds negative months without rollover",{
may <- ymd_hms("2010-05-31 03:04:05")
ends <- ymd_hms(c("2010-04-30 03:04:05", "2010-03-31 03:04:05", "2010-02-28 03:04:05"))
Expand Down

0 comments on commit 00ff073

Please sign in to comment.