Skip to content

Commit

Permalink
Add rounding support for Periods (#24182)
Browse files Browse the repository at this point in the history
* Add rounding for Periods

* Improve period rounding function type signatures

* Use promote instead of Nanosecond to round periods

Period rounding functions no longer unnecessarily convert all values to
nanoseconds prior to rounding.

* Minor refactoring for period rounding
  • Loading branch information
spurll authored and omus committed Nov 2, 2017
1 parent e402daa commit f16a119
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 9 deletions.
121 changes: 113 additions & 8 deletions base/dates/rounding.jl
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ 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))

const ConvertiblePeriod = Union{TimePeriod, Week, Day}
const TimeTypeOrPeriod = Union{TimeType, ConvertiblePeriod}

"""
epochdays2date(days) -> Date
Expand Down Expand Up @@ -76,6 +79,35 @@ function Base.floor(dt::DateTime, p::TimePeriod)
return epochms2datetime(milliseconds - mod(milliseconds, value(Millisecond(p))))
end

"""
floor(x::Period, precision::T) where T <: Union{TimePeriod, Week, Day} -> T
Rounds `x` down to the nearest multiple of `precision`. If `x` and `precision` are different
subtypes of `Period`, the return value will have the same type as `precision`.
For convenience, `precision` may be a type instead of a value: `floor(x, Dates.Hour)` is a
shortcut for `floor(x, Dates.Hour(1))`.
```jldoctest
julia> floor(Dates.Day(16), Dates.Week)
2 weeks
julia> floor(Dates.Minute(44), Dates.Minute(15))
30 minutes
julia> floor(Dates.Hour(36), Dates.Day)
1 day
```
Rounding to a `precision` of `Month`s or `Year`s is not supported, as these `Period`s are of
inconsistent length.
"""
function Base.floor(x::ConvertiblePeriod, precision::T) where T <: ConvertiblePeriod
value(precision) < 1 && throw(DomainError(precision))
_x, _precision = promote(x, precision)
return T(_x - mod(_x, _precision))
end

"""
floor(dt::TimeType, p::Period) -> TimeType
Expand Down Expand Up @@ -121,6 +153,34 @@ function Base.ceil(dt::TimeType, p::Period)
return (dt == f) ? f : f + p
end

"""
ceil(x::Period, precision::T) where T <: Union{TimePeriod, Week, Day} -> T
Rounds `x` up to the nearest multiple of `precision`. If `x` and `precision` are different
subtypes of `Period`, the return value will have the same type as `precision`.
For convenience, `precision` may be a type instead of a value: `ceil(x, Dates.Hour)` is a
shortcut for `ceil(x, Dates.Hour(1))`.
```jldoctest
julia> ceil(Dates.Day(16), Dates.Week)
3 weeks
julia> ceil(Dates.Minute(44), Dates.Minute(15))
45 minutes
julia> ceil(Dates.Hour(36), Dates.Day)
3 days
```
Rounding to a `precision` of `Month`s or `Year`s is not supported, as these `Period`s are of
inconsistent length.
"""
function Base.ceil(x::ConvertiblePeriod, precision::ConvertiblePeriod)
f = floor(x, precision)
return (x == f) ? f : f + precision
end

"""
floorceil(dt::TimeType, p::Period) -> (TimeType, TimeType)
Expand All @@ -132,6 +192,17 @@ function floorceil(dt::TimeType, p::Period)
return f, (dt == f) ? f : f + p
end

"""
floorceil(x::Period, precision::T) where T <: Union{TimePeriod, Week, Day} -> (T, T)
Simultaneously return the `floor` and `ceil` of `Period` at resolution `p`. More efficient
than calling both `floor` and `ceil` individually.
"""
function floorceil(x::ConvertiblePeriod, precision::ConvertiblePeriod)
f = floor(x, precision)
return f, (x == f) ? f : f + precision
end

"""
round(dt::TimeType, p::Period, [r::RoundingMode]) -> TimeType
Expand Down Expand Up @@ -160,21 +231,55 @@ function Base.round(dt::TimeType, p::Period, r::RoundingMode{:NearestTiesUp})
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)
"""
round(x::Period, precision::T, [r::RoundingMode]) where T <: Union{TimePeriod, Week, Day} -> T
Rounds `x` to the nearest multiple of `precision`. If `x` and `precision` are different
subtypes of `Period`, the return value will have the same type as `precision`. By default
(`RoundNearestTiesUp`), ties (e.g., rounding 90 minutes to the nearest hour) will be rounded
up.
For convenience, `precision` may be a type instead of a value: `round(x, Dates.Hour)` is a
shortcut for `round(x, Dates.Hour(1))`.
```jldoctest
julia> round(Dates.Day(16), Dates.Week)
2 weeks
julia> round(Dates.Minute(44), Dates.Minute(15))
45 minutes
julia> round(Dates.Hour(36), Dates.Day)
3 days
```
Valid rounding modes for `round(::Period, ::T, ::RoundingMode)` are `RoundNearestTiesUp`
(default), `RoundDown` (`floor`), and `RoundUp` (`ceil`).
Rounding to a `precision` of `Month`s or `Year`s is not supported, as these `Period`s are of
inconsistent length.
"""
function Base.round(x::ConvertiblePeriod, precision::ConvertiblePeriod, r::RoundingMode{:NearestTiesUp})
f, c = floorceil(x, precision)
_x, _f, _c = promote(x, f, c)
return (_x - _f) < (_c - _x) ? f : c
end

Base.round(x::TimeTypeOrPeriod, p::Period, r::RoundingMode{:Down}) = Base.floor(x, p)
Base.round(x::TimeTypeOrPeriod, p::Period, r::RoundingMode{:Up}) = Base.ceil(x, 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, p::Period, ::RoundingMode) = throw(DomainError(p))
Base.round(::TimeTypeOrPeriod, p::Period, ::RoundingMode) = throw(DomainError(p))

# Default to RoundNearestTiesUp.
Base.round(dt::TimeType, p::Period) = Base.round(dt, p, RoundNearestTiesUp)
Base.round(x::TimeTypeOrPeriod, p::Period) = Base.round(x, p, RoundNearestTiesUp)

# Make rounding functions callable using Period types in addition to values.
Base.floor(dt::TimeType, p::Type{<:Period}) = Base.floor(dt, p(1))
Base.ceil(dt::TimeType, p::Type{<:Period}) = Base.ceil(dt, p(1))
Base.floor(x::TimeTypeOrPeriod, ::Type{P}) where P <: Period = Base.floor(x, oneunit(P))
Base.ceil(x::TimeTypeOrPeriod, ::Type{P}) where P <: Period = Base.ceil(x, oneunit(P))

function Base.round(dt::TimeType, p::Type{<:Period}, r::RoundingMode=RoundNearestTiesUp)
return Base.round(dt, p(1), r)
function Base.round(x::TimeTypeOrPeriod, ::Type{P}, r::RoundingMode=RoundNearestTiesUp) where P <: Period
return Base.round(x, oneunit(P), r)
end
8 changes: 8 additions & 0 deletions doc/src/stdlib/dates.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ Base.ceil(::Base.Dates.TimeType, ::Base.Dates.Period)
Base.round(::Base.Dates.TimeType, ::Base.Dates.Period, ::RoundingMode{:NearestTiesUp})
```

Most `Period` values can also be rounded to a specified resolution:

```@docs
Base.floor(::Base.Dates.ConvertiblePeriod, ::T) where T <: Base.Dates.ConvertiblePeriod
Base.ceil(::Base.Dates.ConvertiblePeriod, ::Base.Dates.ConvertiblePeriod)
Base.round(::Base.Dates.ConvertiblePeriod, ::Base.Dates.ConvertiblePeriod, ::RoundingMode{:NearestTiesUp})
```

The following functions are not exported:

```@docs
Expand Down
76 changes: 75 additions & 1 deletion test/dates/rounding.jl
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ end
@test_throws DomainError round(dt, Dates.Day, RoundToZero)
@test round(dt, Dates.Day) == round(dt, Dates.Day, RoundNearestTiesUp)
end
@testset "Rounding to invalid resolutions" begin
@testset "Rounding datetimes to invalid resolutions" begin
dt = Dates.DateTime(2016, 2, 28, 12, 15)
for p in [Dates.Year, Dates.Month, Dates.Week, Dates.Day, Dates.Hour]
local p
Expand All @@ -144,3 +144,77 @@ end
end
end
end
@testset "Rounding for periods" begin
x = Dates.Second(172799)
@test floor(x, Dates.Week) == Dates.Week(0)
@test floor(x, Dates.Day) == Dates.Day(1)
@test floor(x, Dates.Hour) == Dates.Hour(47)
@test floor(x, Dates.Minute) == Dates.Minute(2879)
@test floor(x, Dates.Second) == Dates.Second(172799)
@test floor(x, Dates.Millisecond) == Dates.Millisecond(172799000)
@test ceil(x, Dates.Week) == Dates.Week(1)
@test ceil(x, Dates.Day) == Dates.Day(2)
@test ceil(x, Dates.Hour) == Dates.Hour(48)
@test ceil(x, Dates.Minute) == Dates.Minute(2880)
@test ceil(x, Dates.Second) == Dates.Second(172799)
@test ceil(x, Dates.Millisecond) == Dates.Millisecond(172799000)
@test round(x, Dates.Week) == Dates.Week(0)
@test round(x, Dates.Day) == Dates.Day(2)
@test round(x, Dates.Hour) == Dates.Hour(48)
@test round(x, Dates.Minute) == Dates.Minute(2880)
@test round(x, Dates.Second) == Dates.Second(172799)
@test round(x, Dates.Millisecond) == Dates.Millisecond(172799000)

x = Dates.Nanosecond(2000999999)
@test floor(x, Dates.Second) == Dates.Second(2)
@test floor(x, Dates.Millisecond) == Dates.Millisecond(2000)
@test floor(x, Dates.Microsecond) == Dates.Microsecond(2000999)
@test floor(x, Dates.Nanosecond) == x
@test ceil(x, Dates.Second) == Dates.Second(3)
@test ceil(x, Dates.Millisecond) == Dates.Millisecond(2001)
@test ceil(x, Dates.Microsecond) == Dates.Microsecond(2001000)
@test ceil(x, Dates.Nanosecond) == x
@test round(x, Dates.Second) == Dates.Second(2)
@test round(x, Dates.Millisecond) == Dates.Millisecond(2001)
@test round(x, Dates.Microsecond) == Dates.Microsecond(2001000)
@test round(x, Dates.Nanosecond) == x
end
@testset "Rounding for periods that should not need rounding" begin
for x in [Dates.Week(3), Dates.Day(14), Dates.Second(604800)]
local x
for p in [Dates.Week, Dates.Day, Dates.Hour, Dates.Second, Dates.Millisecond, Dates.Microsecond, Dates.Nanosecond]
local p
@test floor(x, p) == p(x)
@test ceil(x, p) == p(x)
end
end
end
@testset "Various available RoundingModes for periods" begin
x = Dates.Hour(36)
@test round(x, Dates.Day, RoundNearestTiesUp) == Dates.Day(2)
@test round(x, Dates.Day, RoundUp) == Dates.Day(2)
@test round(x, Dates.Day, RoundDown) == Dates.Day(1)
@test_throws DomainError round(x, Dates.Day, RoundNearest)
@test_throws DomainError round(x, Dates.Day, RoundNearestTiesAway)
@test_throws DomainError round(x, Dates.Day, RoundToZero)
@test round(x, Dates.Day) == round(x, Dates.Day, RoundNearestTiesUp)
end
@testset "Rounding periods to invalid resolutions" begin
x = Dates.Hour(86399)
for p in [Dates.Week, Dates.Day, Dates.Hour, Dates.Second, Dates.Millisecond, Dates.Microsecond, Dates.Nanosecond]
local p
for v in [-1, 0]
@test_throws DomainError floor(x, p(v))
@test_throws DomainError ceil(x, p(v))
@test_throws DomainError round(x, p(v))
end
end
for p in [Dates.Year, Dates.Month]
local p
for v in [-1, 0, 1]
@test_throws MethodError floor(x, p(v))
@test_throws MethodError ceil(x, p(v))
@test_throws DomainError round(x, p(v))
end
end
end

0 comments on commit f16a119

Please sign in to comment.