From c65e5fdc460747956e0577e2945364dd76b7b4a8 Mon Sep 17 00:00:00 2001 From: Gem Newman Date: Fri, 8 Jul 2016 19:57:39 -0500 Subject: [PATCH] Date Rounding (#17037) * Added floor, ceil, round for Date and DateTime. * Refactoring and documentation for date rounding. * Added PR reference in NEWS.md. * Bugfix for date rounding test in 32-bit. Doc clarifications. * Round to nonpositive resolution throws DomainError Throw DomainError on rounding to an invalid (non-positive) resolution Clean up test cases for rounding dates that don't need rounding Add test cases for rounding to invalid (non-positive) resolutions --- NEWS.md | 4 + base/dates/Dates.jl | 1 + base/dates/rounding.jl | 178 ++++++++++++++++++++++++++++++++++++++ doc/manual/dates.rst | 91 +++++++++++++++++++ doc/stdlib/dates.rst | 97 +++++++++++++++++++++ test/dates.jl | 1 + test/dates/conversions.jl | 1 + test/dates/rounding.jl | 139 +++++++++++++++++++++++++++++ 8 files changed, 512 insertions(+) create mode 100644 base/dates/rounding.jl create mode 100644 test/dates/rounding.jl diff --git a/NEWS.md b/NEWS.md index d332f885826bc..d4143b0a6b066 100644 --- a/NEWS.md +++ b/NEWS.md @@ -206,6 +206,9 @@ Library improvements * Prime number related functions have been moved from `Base` to the [Primes.jl package](https://github.com/JuliaMath/Primes.jl) ([#16481]). + * `Date` and `DateTime` values can now be rounded to a specified resolution (e.g., 1 month or + 15 minutes) with `floor`, `ceil`, and `round` ([#17037]). + Deprecated or removed --------------------- @@ -297,3 +300,4 @@ Deprecated or removed [#16731]: https://github.com/JuliaLang/julia/issues/16731 [#16972]: https://github.com/JuliaLang/julia/issues/16972 [#17266]: https://github.com/JuliaLang/julia/issues/17266 +[#17037]: https://github.com/JuliaLang/julia/issues/17037 diff --git a/base/dates/Dates.jl b/base/dates/Dates.jl index f2efebd4a00aa..ff2165de586d0 100644 --- a/base/dates/Dates.jl +++ b/base/dates/Dates.jl @@ -12,6 +12,7 @@ include("arithmetic.jl") include("conversions.jl") include("ranges.jl") include("adjusters.jl") +include("rounding.jl") include("io.jl") export Period, DatePeriod, TimePeriod, diff --git a/base/dates/rounding.jl b/base/dates/rounding.jl new file mode 100644 index 0000000000000..e9a08c6fc13c2 --- /dev/null +++ b/base/dates/rounding.jl @@ -0,0 +1,178 @@ +# The epochs used for date rounding are based ISO 8601's "year zero" notation +const DATEEPOCH = value(Date(0)) +const DATETIMEEPOCH = value(DateTime(0)) + +# According to ISO 8601, the first day of the first week of year 0000 is 0000-01-03 +const WEEKEPOCH = value(Date(0, 1, 3)) + +""" + epochdays2date(days) -> Date + +Takes the number of days since the rounding epoch (`0000-01-01T00:00:00`) and returns the +corresponding `Date`. +""" +epochdays2date(i) = Date(UTD(DATEEPOCH + Int64(i))) + +""" + epochms2datetime(milliseconds) -> DateTime + +Takes the number of milliseconds since the rounding epoch (`0000-01-01T00:00:00`) and +returns the corresponding `DateTime`. +""" +epochms2datetime(i) = DateTime(UTM(DATETIMEEPOCH + Int64(i))) + +""" + date2epochdays(dt::Date) -> Int64 + +Takes the given `Date` and returns the number of days since the rounding epoch +(`0000-01-01T00:00:00`) as an `Int64`. +""" +date2epochdays(dt::Date) = value(dt) - DATEEPOCH + +""" + datetime2epochms(dt::DateTime) -> Int64 + +Takes the given `DateTime` and returns the number of milliseconds since the rounding epoch +(`0000-01-01T00:00:00`) as an `Int64`. +""" +datetime2epochms(dt::DateTime) = value(dt) - DATETIMEEPOCH + +function Base.floor(dt::Date, p::Year) + value(p) < 1 && throw(DomainError()) + years = year(dt) + return Date(years - mod(years, value(p))) +end + +function Base.floor(dt::Date, p::Month) + value(p) < 1 && throw(DomainError()) + y, m = yearmonth(dt) + months_since_epoch = y * 12 + m - 1 + month_offset = months_since_epoch - mod(months_since_epoch, value(p)) + target_month = mod(month_offset, 12) + 1 + target_year = div(month_offset, 12) - (month_offset < 0 && target_month != 1) + return Date(target_year, target_month) +end + +function Base.floor(dt::Date, p::Week) + value(p) < 1 && throw(DomainError()) + days = value(dt) - WEEKEPOCH + days = days - mod(days, value(Day(p))) + return Date(UTD(WEEKEPOCH + Int64(days))) +end + +function Base.floor(dt::Date, p::Day) + value(p) < 1 && throw(DomainError()) + days = date2epochdays(dt) + return epochdays2date(days - mod(days, value(p))) +end + +Base.floor(dt::DateTime, p::DatePeriod) = DateTime(Base.floor(Date(dt), p)) + +function Base.floor(dt::DateTime, p::TimePeriod) + value(p) < 1 && throw(DomainError()) + milliseconds = datetime2epochms(dt) + return epochms2datetime(milliseconds - mod(milliseconds, value(Millisecond(p)))) +end + +""" + floor(dt::TimeType, p::Period) -> TimeType + +Returns the nearest `Date` or `DateTime` less than or equal to `dt` at resolution `p`. + +For convenience, `p` may be a type instead of a value: `floor(dt, Dates.Hour)` is a shortcut +for `floor(dt, Dates.Hour(1))`. + +```jldoctest +julia> floor(Date(1985, 8, 16), Dates.Month) +1985-08-01 + +julia> floor(DateTime(2013, 2, 13, 0, 31, 20), Dates.Minute(15)) +2013-02-13T00:30:00 + +julia> floor(DateTime(2016, 8, 6, 12, 0, 0), Dates.Day) +2016-08-06T00:00:00 +``` +""" +Base.floor(::Dates.TimeType, ::Dates.Period) + +""" + ceil(dt::TimeType, p::Period) -> TimeType + +Returns the nearest `Date` or `DateTime` greater than or equal to `dt` at resolution `p`. + +For convenience, `p` may be a type instead of a value: `ceil(dt, Dates.Hour)` is a shortcut +for `ceil(dt, Dates.Hour(1))`. + +```jldoctest +julia> ceil(Date(1985, 8, 16), Dates.Month) +1985-09-01 + +julia> ceil(DateTime(2013, 2, 13, 0, 31, 20), Dates.Minute(15)) +2013-02-13T00:45:00 + +julia> ceil(DateTime(2016, 8, 6, 12, 0, 0), Dates.Day) +2016-08-07T00:00:00 +``` +""" +function Base.ceil(dt::TimeType, p::Period) + f = floor(dt, p) + return (dt == f) ? f : f + p +end + +""" + floorceil(dt::TimeType, p::Period) -> (TimeType, TimeType) + +Simultaneously return the `floor` and `ceil` of a `Date` or `DateTime` at resolution `p`. +More efficient than calling both `floor` and `ceil` individually. +""" +function floorceil(dt::TimeType, p::Period) + f = floor(dt, p) + return f, (dt == f) ? f : f + p +end + +""" + round(dt::TimeType, p::Period, [r::RoundingMode]) -> TimeType + +Returns the `Date` or `DateTime` nearest to `dt` at resolution `p`. By default +(`RoundNearestTiesUp`), ties (e.g., rounding 9:30 to the nearest hour) will be rounded up. + +For convenience, `p` may be a type instead of a value: `round(dt, Dates.Hour)` is a shortcut +for `round(dt, Dates.Hour(1))`. + +```jldoctest +julia> round(Date(1985, 8, 16), Dates.Month) +1985-08-01 + +julia> round(DateTime(2013, 2, 13, 0, 31, 20), Dates.Minute(15)) +2013-02-13T00:30:00 + +julia> round(DateTime(2016, 8, 6, 12, 0, 0), Dates.Day) +2016-08-07T00:00:00 +``` + +Valid rounding modes for `round(::TimeType, ::Period, ::RoundingMode)` are +`RoundNearestTiesUp` (default), `RoundDown` (`floor`), and `RoundUp` (`ceil`). +""" +function Base.round(dt::TimeType, p::Period, r::RoundingMode{:NearestTiesUp}) + f, c = floorceil(dt, p) + return (dt - f) < (c - dt) ? f : c +end + +Base.round(dt::TimeType, p::Period, r::RoundingMode{:Down}) = Base.floor(dt, p) +Base.round(dt::TimeType, p::Period, r::RoundingMode{:Up}) = Base.ceil(dt, p) + +# No implementation of other `RoundingMode`s: rounding to nearest "even" is skipped because +# "even" is not defined for Period; rounding toward/away from zero is skipped because ISO +# 8601's year 0000 is not really "zero". +Base.round(::TimeType, ::Period, ::RoundingMode) = throw(DomainError()) + +# Default to RoundNearestTiesUp. +Base.round(dt::TimeType, p::Period) = Base.round(dt, p, RoundNearestTiesUp) + +# Make rounding functions callable using Period types in addition to values. +Base.floor{T <: Period}(dt::TimeType, p::Type{T}) = Base.floor(dt, p(1)) +Base.ceil{T <: Period}(dt::TimeType, p::Type{T}) = Base.ceil(dt, p(1)) + +function Base.round{T<:Period}(dt::TimeType, p::Type{T}, r::RoundingMode=RoundNearestTiesUp) + return Base.round(dt, p(1), r) +end diff --git a/doc/manual/dates.rst b/doc/manual/dates.rst index bf589a46fad9f..08b5ba148b228 100644 --- a/doc/manual/dates.rst +++ b/doc/manual/dates.rst @@ -367,4 +367,95 @@ Periods are a human view of discrete, sometimes irregular durations of time. Con 3 years +Rounding +-------- + +:class:`Date` and :class:`DateTime` values can be rounded to a specified resolution (e.g., +1 month or 15 minutes) with :func:`floor`, :func:`ceil`, or :func:`round`: + +.. doctest:: + + julia> floor(Date(1985, 8, 16), Dates.Month) + 1985-08-01 + + julia> ceil(DateTime(2013, 2, 13, 0, 31, 20), Dates.Minute(15)) + 2013-02-13T00:45:00 + + julia> round(DateTime(2016, 8, 6, 20, 15), Dates.Day) + 2016-08-07T00:00:00 + +Unlike the numeric :func:`round` method, which breaks ties toward the even number by +default, the :class:`TimeType` :func:`round` method uses the ``RoundNearestTiesUp`` +rounding mode. (It's difficult to guess what breaking ties to nearest "even" +:class:`TimeType` would entail.) Further details on the available ``RoundingMode`` s can +be found in the +`API reference `_. + +Rounding should generally behave as expected, but there are a few cases in which the +expected behaviour is not obvious. + +Rounding Epoch +~~~~~~~~~~~~~~ + +In many cases, the resolution specified for rounding (e.g., ``Dates.Second(30)``) divides +evenly into the next largest period (in this case, ``Dates.Minute(1)``). But rounding +behaviour in cases in which this is not true may lead to confusion. What is the expected +result of rounding a :class:`DateTime` to the nearest 10 hours? + +.. doctest:: + + julia> round(DateTime(2016, 7, 17, 11, 55), Dates.Hour(10)) + 2016-07-17T12:00:00 + +That may seem confusing, given that the hour (12) is not divisible by 10. The reason that +``2016-07-17T12:00:00`` was chosen is that it is 17,676,660 hours after +``0000-01-01T00:00:00``, and 17,676,660 is divisible by 10. + +As Julia :class:`Date` and :class:`DateTime` values are represented according to the ISO +8601 standard, ``0000-01-01T00:00:00`` was chosen as base (or "rounding epoch") from +which to begin the count of days (and milliseconds) used in rounding calculations. (Note +that this differs slightly from Julia's internal representation of :class:`Date` s using +Rata Die notation; but since the ISO 8601 standard is most visible to the end user, +``0000-01-01T00:00:00`` was chosen as the rounding epoch instead of the +``0000-12-31T00:00:00`` used internally to minimize confusion.) + +The only exception to the use of ``0000-01-01T00:00:00`` as the rounding epoch is when +rounding to weeks. Rounding to the nearest week will always return a Monday (the first day +of the week as specified by ISO 8601). For this reason, we use ``0000-01-03T00:00:00`` +(the first day of the first week of year 0000, as defined by ISO 8601) as the base when +rounding to a number of weeks. + +Here is a related case in which the expected behaviour is not necessarily obvious: What +happens when we round to the nearest ``P(2)``, where ``P`` is a :class:`Period` type? In +some cases (specifically, when ``P <: Dates.TimePeriod``) the answer is clear: + +.. doctest:: + + julia> round(DateTime(2016, 7, 17, 8, 55, 30), Dates.Hour(2)) + 2016-07-17T08:00:00 + + julia> round(DateTime(2016, 7, 17, 8, 55, 30), Dates.Minute(2)) + 2016-07-17T08:56:00 + +This seems obvious, because two of each of these periods still divides evenly into the +next larger order period. But in the case of two months (which still divides evenly into +one year), the answer may be surprising: + +.. doctest:: + + julia> round(DateTime(2016, 7, 17, 8, 55, 30), Dates.Month(2)) + 2016-07-01T00:00:00 + +Why round to the first day in July, even though it is month 7 (an odd number)? The key is +that months are 1-indexed (the first month is assigned 1), unlike hours, minutes, seconds, +and milliseconds (the first of which are assigned 0). + +This means that rounding a :class:`DateTime` to an even multiple of seconds, minutes, +hours, or years (because the ISO 8601 specification includes a year zero) will result in +a :class:`DateTime` with an even value in that field, while rounding a :class:`DateTime` +to an even multiple of months will result in the months field having an odd value. Because +both months and years may contain an irregular number of days, whether rounding to an even +number of days will result in an even value in the days field is uncertain. + + See the `API reference `_ for additional information on methods exported from the :mod:`Dates` module. diff --git a/doc/stdlib/dates.rst b/doc/stdlib/dates.rst index dd9ef62218a81..1cc554b42b613 100644 --- a/doc/stdlib/dates.rst +++ b/doc/stdlib/dates.rst @@ -575,6 +575,103 @@ Periods Returns a sensible "default" value for the input Period by returning ``one(p)`` for Year, Month, and Day, and ``zero(p)`` for Hour, Minute, Second, and Millisecond. +Rounding Functions +~~~~~~~~~~~~~~~~~~ + +``Date`` and ``DateTime`` values can be rounded to a specified resolution (e.g., 1 month +or 15 minutes) with ``floor``, ``ceil``, or ``round``. + +.. function:: floor(dt::TimeType, p::Period) -> TimeType + + .. Docstring generated from Julia source + + Returns the nearest ``Date`` or ``DateTime`` less than or equal to ``dt`` at resolution ``p``\ . + + For convenience, ``p`` may be a type instead of a value: ``floor(dt, Dates.Hour)`` is a shortcut for ``floor(dt, Dates.Hour(1))``\ . + + .. doctest:: + + julia> floor(Date(1985, 8, 16), Dates.Month) + 1985-08-01 + + julia> floor(DateTime(2013, 2, 13, 0, 31, 20), Dates.Minute(15)) + 2013-02-13T00:30:00 + + julia> floor(DateTime(2016, 8, 6, 12, 0, 0), Dates.Day) + 2016-08-06T00:00:00 + +.. function:: ceil(dt::TimeType, p::Period) -> TimeType + + .. Docstring generated from Julia source + + Returns the nearest ``Date`` or ``DateTime`` greater than or equal to ``dt`` at resolution ``p``\ . + + For convenience, ``p`` may be a type instead of a value: ``ceil(dt, Dates.Hour)`` is a shortcut for ``ceil(dt, Dates.Hour(1))``\ . + + .. doctest:: + + julia> ceil(Date(1985, 8, 16), Dates.Month) + 1985-09-01 + + julia> ceil(DateTime(2013, 2, 13, 0, 31, 20), Dates.Minute(15)) + 2013-02-13T00:45:00 + + julia> ceil(DateTime(2016, 8, 6, 12, 0, 0), Dates.Day) + 2016-08-07T00:00:00 + +.. function:: round(dt::TimeType, p::Period, [r::RoundingMode]) -> TimeType + + .. Docstring generated from Julia source + + Returns the ``Date`` or ``DateTime`` nearest to ``dt`` at resolution ``p``\ . By default (``RoundNearestTiesUp``\ ), ties (e.g., rounding 9:30 to the nearest hour) will be rounded up. + + For convenience, ``p`` may be a type instead of a value: ``round(dt, Dates.Hour)`` is a shortcut for ``round(dt, Dates.Hour(1))``\ . + + .. doctest:: + + julia> round(Date(1985, 8, 16), Dates.Month) + 1985-08-01 + + julia> round(DateTime(2013, 2, 13, 0, 31, 20), Dates.Minute(15)) + 2013-02-13T00:30:00 + + julia> round(DateTime(2016, 8, 6, 12, 0, 0), Dates.Day) + 2016-08-07T00:00:00 + + Valid rounding modes for ``round(::TimeType, ::Period, ::RoundingMode)`` are ``RoundNearestTiesUp`` (default), ``RoundDown`` (``floor``\ ), and ``RoundUp`` (``ceil``\ ). + +The following functions are not exported: + +.. function:: floorceil(dt::TimeType, p::Period) -> (TimeType, TimeType) + + .. Docstring generated from Julia source + + Simultaneously return the ``floor`` and ``ceil`` of a ``Date`` or ``DateTime`` at resolution ``p``\ . More efficient than calling both ``floor`` and ``ceil`` individually. + +.. function:: epochdays2date(days) -> Date + + .. Docstring generated from Julia source + + Takes the number of days since the rounding epoch (``0000-01-01T00:00:00``\ ) and returns the corresponding ``Date``\ . + +.. function:: epochms2datetime(milliseconds) -> DateTime + + .. Docstring generated from Julia source + + Takes the number of milliseconds since the rounding epoch (``0000-01-01T00:00:00``\ ) and returns the corresponding ``DateTime``\ . + +.. function:: date2epochdays(dt::Date) -> Int64 + + .. Docstring generated from Julia source + + Takes the given ``Date`` and returns the number of days since the rounding epoch (``0000-01-01T00:00:00``\ ) as an ``Int64``\ . + +.. function:: datetime2epochms(dt::DateTime) -> Int64 + + .. Docstring generated from Julia source + + Takes the given ``DateTime`` and returns the number of milliseconds since the rounding epoch (``0000-01-01T00:00:00``\ ) as an ``Int64``\ . + Conversion Functions ~~~~~~~~~~~~~~~~~~~~ diff --git a/test/dates.jl b/test/dates.jl index 6c8467a9f1acc..ea654b7c2fe47 100644 --- a/test/dates.jl +++ b/test/dates.jl @@ -13,6 +13,7 @@ include("dates/arithmetic.jl") include("dates/conversions.jl") include("dates/ranges.jl") include("dates/adjusters.jl") +include("dates/rounding.jl") include("dates/io.jl") end diff --git a/test/dates/conversions.jl b/test/dates/conversions.jl index 3706c9ef96aa7..db3fd97948ee0 100644 --- a/test/dates/conversions.jl +++ b/test/dates/conversions.jl @@ -28,6 +28,7 @@ @test string(Dates.unix2datetime(915148801.00)) == string("1999-01-01T00:00:01") @test string(Dates.unix2datetime(915148801.25)) == string("1999-01-01T00:00:01.25") +# Test conversion to and from Rata Die @test Date(Dates.rata2datetime(734869)) == Dates.Date(2013,1,1) @test Dates.datetime2rata(Dates.rata2datetime(734869)) == 734869 diff --git a/test/dates/rounding.jl b/test/dates/rounding.jl new file mode 100644 index 0000000000000..80218fcf0db47 --- /dev/null +++ b/test/dates/rounding.jl @@ -0,0 +1,139 @@ +# Test conversion to and from the rounding epoch (ISO 8601 year 0000) +@test Dates.epochdays2date(-1) == Dates.Date(-1, 12, 31) +@test Dates.epochdays2date(0) == Dates.Date(0, 1, 1) +@test Dates.epochdays2date(1) == Dates.Date(0, 1, 2) +@test Dates.epochdays2date(736329) == Dates.Date(2016, 1, 1) +@test Dates.epochms2datetime(-86400000) == Dates.DateTime(-1, 12, 31) +@test Dates.epochms2datetime(0) == Dates.DateTime(0, 1, 1) +@test Dates.epochms2datetime(86400000) == Dates.DateTime(0, 1, 2) +@test Dates.epochms2datetime(Int64(736329) * 86400000) == Dates.DateTime(2016, 1, 1) +@test Dates.date2epochdays(Dates.Date(-1, 12, 31)) == -1 +@test Dates.date2epochdays(Dates.Date(0, 1, 1)) == 0 +@test Dates.date2epochdays(Dates.Date(2016, 1, 1)) == 736329 +@test Dates.datetime2epochms(Dates.DateTime(-1, 12, 31)) == -86400000 +@test Dates.datetime2epochms(Dates.DateTime(0, 1, 1)) == 0 +@test Dates.datetime2epochms(Dates.DateTime(2016, 1, 1)) == Int64(736329) * 86400000 + +# Basic rounding tests +dt = Dates.Date(2016, 2, 28) # Sunday +@test floor(dt, Dates.Year) == Dates.Date(2016) +@test floor(dt, Dates.Year(5)) == Dates.Date(2015) +@test floor(dt, Dates.Year(10)) == Dates.Date(2010) +@test floor(dt, Dates.Month) == Dates.Date(2016, 2) +@test floor(dt, Dates.Month(6)) == Dates.Date(2016, 1) +@test floor(dt, Dates.Week) == toprev(dt, Dates.Monday) +@test ceil(dt, Dates.Year) == Dates.Date(2017) +@test ceil(dt, Dates.Year(5)) == Dates.Date(2020) +@test ceil(dt, Dates.Month) == Dates.Date(2016, 3) +@test ceil(dt, Dates.Month(6)) == Dates.Date(2016, 7) +@test ceil(dt, Dates.Week) == tonext(dt, Dates.Monday) +@test round(dt, Dates.Year) == Dates.Date(2016) +@test round(dt, Dates.Month) == Dates.Date(2016, 3) +@test round(dt, Dates.Week) == Dates.Date(2016, 2, 29) + +dt = Dates.DateTime(2016, 2, 28, 15, 10, 50, 500) +@test floor(dt, Dates.Day) == Dates.DateTime(2016, 2, 28) +@test floor(dt, Dates.Hour) == Dates.DateTime(2016, 2, 28, 15) +@test floor(dt, Dates.Hour(2)) == Dates.DateTime(2016, 2, 28, 14) +@test floor(dt, Dates.Hour(12)) == Dates.DateTime(2016, 2, 28, 12) +@test floor(dt, Dates.Minute) == Dates.DateTime(2016, 2, 28, 15, 10) +@test floor(dt, Dates.Minute(15)) == Dates.DateTime(2016, 2, 28, 15, 0) +@test floor(dt, Dates.Second) == Dates.DateTime(2016, 2, 28, 15, 10, 50) +@test floor(dt, Dates.Second(30)) == Dates.DateTime(2016, 2, 28, 15, 10, 30) +@test ceil(dt, Dates.Day) == Dates.DateTime(2016, 2, 29) +@test ceil(dt, Dates.Hour) == Dates.DateTime(2016, 2, 28, 16) +@test ceil(dt, Dates.Hour(2)) == Dates.DateTime(2016, 2, 28, 16) +@test ceil(dt, Dates.Hour(12)) == Dates.DateTime(2016, 2, 29, 0) +@test ceil(dt, Dates.Minute) == Dates.DateTime(2016, 2, 28, 15, 11) +@test ceil(dt, Dates.Minute(15)) == Dates.DateTime(2016, 2, 28, 15, 15) +@test ceil(dt, Dates.Second) == Dates.DateTime(2016, 2, 28, 15, 10, 51) +@test ceil(dt, Dates.Second(30)) == Dates.DateTime(2016, 2, 28, 15, 11, 0) +@test round(dt, Dates.Day) == Dates.DateTime(2016, 2, 29) +@test round(dt, Dates.Hour) == Dates.DateTime(2016, 2, 28, 15) +@test round(dt, Dates.Hour(2)) == Dates.DateTime(2016, 2, 28, 16) +@test round(dt, Dates.Hour(12)) == Dates.DateTime(2016, 2, 28, 12) +@test round(dt, Dates.Minute) == Dates.DateTime(2016, 2, 28, 15, 11) +@test round(dt, Dates.Minute(15)) == Dates.DateTime(2016, 2, 28, 15, 15) +@test round(dt, Dates.Second) == Dates.DateTime(2016, 2, 28, 15, 10, 51) +@test round(dt, Dates.Second(30)) == Dates.DateTime(2016, 2, 28, 15, 11, 0) + +# Rounding for dates at the rounding epoch (year 0000) +dt = Dates.DateTime(0) +@test floor(dt, Dates.Year) == dt +@test floor(dt, Dates.Month) == dt +@test floor(dt, Dates.Week) == Dates.Date(-1, 12, 27) # Monday prior to 0000-01-01 +@test floor(Dates.Date(-1, 12, 27), Dates.Week) == Dates.Date(-1, 12, 27) +@test floor(dt, Dates.Day) == dt +@test floor(dt, Dates.Hour) == dt +@test floor(dt, Dates.Minute) == dt +@test floor(dt, Dates.Second) == dt +@test ceil(dt, Dates.Year) == dt +@test ceil(dt, Dates.Month) == dt +@test ceil(dt, Dates.Week) == Dates.Date(0, 1, 3) # Monday following 0000-01-01 +@test ceil(Dates.Date(0, 1, 3), Dates.Week) == Dates.Date(0, 1, 3) +@test ceil(dt, Dates.Day) == dt +@test ceil(dt, Dates.Hour) == dt +@test ceil(dt, Dates.Minute) == dt +@test ceil(dt, Dates.Second) == dt + +# Test rounding for multiples of a period (easiest to test close to rounding epoch) +dt = Dates.DateTime(0, 1, 19, 19, 19, 19, 19) +@test floor(dt, Dates.Year(2)) == DateTime(0) +@test floor(dt, Dates.Month(2)) == DateTime(0, 1) # Odd number; months are 1-indexed +@test floor(dt, Dates.Week(2)) == DateTime(0, 1, 17) # Third Monday of 0000 +@test floor(dt, Dates.Day(2)) == DateTime(0, 1, 19) # Odd number; days are 1-indexed +@test floor(dt, Dates.Hour(2)) == DateTime(0, 1, 19, 18) +@test floor(dt, Dates.Minute(2)) == DateTime(0, 1, 19, 19, 18) +@test floor(dt, Dates.Second(2)) == DateTime(0, 1, 19, 19, 19, 18) +@test ceil(dt, Dates.Year(2)) == DateTime(2) +@test ceil(dt, Dates.Month(2)) == DateTime(0, 3) # Odd number; months are 1-indexed +@test ceil(dt, Dates.Week(2)) == DateTime(0, 1, 31) # Fifth Monday of 0000 +@test ceil(dt, Dates.Day(2)) == DateTime(0, 1, 21) # Odd number; days are 1-indexed +@test ceil(dt, Dates.Hour(2)) == DateTime(0, 1, 19, 20) +@test ceil(dt, Dates.Minute(2)) == DateTime(0, 1, 19, 19, 20) +@test ceil(dt, Dates.Second(2)) == DateTime(0, 1, 19, 19, 19, 20) + +# Test rounding for dates with negative years +dt = Dates.DateTime(-1, 12, 29, 19, 19, 19, 19) +@test floor(dt, Dates.Year(2)) == DateTime(-2) +@test floor(dt, Dates.Month(2)) == DateTime(-1, 11) # Odd number; months are 1-indexed +@test floor(dt, Dates.Week(2)) == DateTime(-1, 12, 20) # 2 weeks prior to 0000-01-03 +@test floor(dt, Dates.Day(2)) == DateTime(-1, 12, 28) # Even; 4 days prior to 0000-01-01 +@test floor(dt, Dates.Hour(2)) == DateTime(-1, 12, 29, 18) +@test floor(dt, Dates.Minute(2)) == DateTime(-1, 12, 29, 19, 18) +@test floor(dt, Dates.Second(2)) == DateTime(-1, 12, 29, 19, 19, 18) +@test ceil(dt, Dates.Year(2)) == DateTime(0) +@test ceil(dt, Dates.Month(2)) == DateTime(0, 1) # Odd number; months are 1-indexed +@test ceil(dt, Dates.Week(2)) == DateTime(0, 1, 3) # First Monday of 0000 +@test ceil(dt, Dates.Day(2)) == DateTime(-1, 12, 30) # Even; 2 days prior to 0000-01-01 +@test ceil(dt, Dates.Hour(2)) == DateTime(-1, 12, 29, 20) +@test ceil(dt, Dates.Minute(2)) == DateTime(-1, 12, 29, 19, 20) +@test ceil(dt, Dates.Second(2)) == DateTime(-1, 12, 29, 19, 19, 20) + +# Test rounding for dates that should not need rounding +for dt in [Dates.DateTime(2016, 1, 1), Dates.DateTime(-2016, 1, 1)] + for p in [Dates.Year, Dates.Month, Dates.Day, Dates.Hour, Dates.Minute, Dates.Second] + @test floor(dt, p) == dt + @test ceil(dt, p) == dt + end +end + +# Test available RoundingModes +dt = Dates.DateTime(2016, 2, 28, 12) +@test round(dt, Dates.Day, RoundNearestTiesUp) == Dates.DateTime(2016, 2, 29) +@test round(dt, Dates.Day, RoundUp) == Dates.DateTime(2016, 2, 29) +@test round(dt, Dates.Day, RoundDown) == Dates.DateTime(2016, 2, 28) +@test_throws DomainError round(dt, Dates.Day, RoundNearest) +@test_throws DomainError round(dt, Dates.Day, RoundNearestTiesAway) +@test_throws DomainError round(dt, Dates.Day, RoundToZero) +@test round(dt, Dates.Day) == round(dt, Dates.Day, RoundNearestTiesUp) + +# Test rounding to invalid resolutions +dt = Dates.DateTime(2016, 2, 28, 12, 15) +for p in [Dates.Year, Dates.Month, Dates.Week, Dates.Day, Dates.Hour] + for v in [-1, 0] + @test_throws DomainError floor(dt, p(v)) + @test_throws DomainError ceil(dt, p(v)) + @test_throws DomainError round(dt, p(v)) + end +end