Skip to content

Commit

Permalink
Date Rounding (#17037)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
spurll authored and tkelman committed Jul 9, 2016
1 parent bd2ac12 commit c65e5fd
Show file tree
Hide file tree
Showing 8 changed files with 512 additions and 0 deletions.
4 changes: 4 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------------

Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions base/dates/Dates.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
178 changes: 178 additions & 0 deletions base/dates/rounding.jl
Original file line number Diff line number Diff line change
@@ -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
91 changes: 91 additions & 0 deletions doc/manual/dates.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://docs.julialang.org/en/latest/stdlib/dates/#rounding-functions>`_.

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 <http://docs.julialang.org/en/latest/stdlib/dates/>`_ for additional information on methods exported from the :mod:`Dates` module.
Loading

0 comments on commit c65e5fd

Please sign in to comment.