From f16a1191886d3a72820526eb3db27c3bfb9d336e Mon Sep 17 00:00:00 2001 From: Gem Newman Date: Thu, 2 Nov 2017 07:39:29 -0500 Subject: [PATCH] Add rounding support for `Periods` (#24182) * 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 --- base/dates/rounding.jl | 121 +++++++++++++++++++++++++++++++++++++--- doc/src/stdlib/dates.md | 8 +++ test/dates/rounding.jl | 76 ++++++++++++++++++++++++- 3 files changed, 196 insertions(+), 9 deletions(-) diff --git a/base/dates/rounding.jl b/base/dates/rounding.jl index 6a4a40d38ae13..d8efe47f1a94c 100644 --- a/base/dates/rounding.jl +++ b/base/dates/rounding.jl @@ -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 @@ -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 @@ -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) @@ -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 @@ -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 diff --git a/doc/src/stdlib/dates.md b/doc/src/stdlib/dates.md index a7ebd9f3d64dd..b7ff1f950bda0 100644 --- a/doc/src/stdlib/dates.md +++ b/doc/src/stdlib/dates.md @@ -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 diff --git a/test/dates/rounding.jl b/test/dates/rounding.jl index b68c14ba80eb4..b12d1a757a992 100644 --- a/test/dates/rounding.jl +++ b/test/dates/rounding.jl @@ -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 @@ -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