diff --git a/src/docstrings.jl b/src/docstrings.jl index 607bf555..e7263845 100644 --- a/src/docstrings.jl +++ b/src/docstrings.jl @@ -105,3 +105,31 @@ Note using `!isbounded` is commonly used to determine if any end of the interval unbounded. """ isbounded(::AbstractInterval) + +""" + minimum(interval::AbstractInterval{T}; [increment]) -> T + +The minimum value in the interval contained within the `interval`. + +If left-closed, returns `first(interval)`. +If left-open, returns `first(interval) + eps(first(interval))` +If left-unbounded, returns minimum value possible for type `T`. + +A `BoundsError` is thrown for empty intervals or when the increment results in a minimum value +not-contained by the interval. +""" +minimum(::AbstractInterval; increment) + +""" + maximum(interval::AbstractInterval{T}; increment::Any) -> T + +The maximum value in the interval contained within the `interval`. + +If right-closed, returns `last(interval)`. +If right-open, returns `first(interval) + eps(first(interval))` +If right-unbounded, returns maximum value possible for type `T`. + +A `BoundsError` is thrown for empty intervals or when the increment results in a maximum value +not-contained by the interval. +""" +maximum(::AbstractInterval; increment) diff --git a/src/interval.jl b/src/interval.jl index b4929ec3..281c9842 100644 --- a/src/interval.jl +++ b/src/interval.jl @@ -187,6 +187,65 @@ Base.isopen(interval::AbstractInterval{T,L,R}) where {T,L,R} = L === Open && R = isunbounded(interval::AbstractInterval{T,L,R}) where {T,L,R} = L === Unbounded && R === Unbounded isbounded(interval::AbstractInterval{T,L,R}) where {T,L,R} = L !== Unbounded && R !== Unbounded +function Base.minimum(interval::AbstractInterval{T,L,R}; increment=nothing) where {T,L,R} + return L === Unbounded ? typemin(T) : first(interval) +end + +function Base.minimum(interval::AbstractInterval{T,Open,R}; increment=eps(T)) where {T,R} + isempty(interval) && throw(BoundsError(interval, 0)) + min_val = first(interval) + increment + # Since intervals can't have NaN, we can just use !isfinite to check if infinite + !isfinite(min_val) && return typemin(T) + min_val ∈ interval && return min_val + throw(BoundsError(interval, min_val)) +end + +function Base.minimum(interval::AbstractInterval{T,Open,R}) where {T<:Integer,R} + return minimum(interval, increment=one(T)) +end + +function Base.minimum(interval::AbstractInterval{T,Open,R}; increment=nothing) where {T<:AbstractFloat,R} + isempty(interval) && throw(BoundsError(interval, 0)) + min_val = first(interval) + # Since intervals can't have NaN, we can just use !isfinite to check if infinite + next_val = if !isfinite(min_val) || increment === nothing + nextfloat(min_val) + else + min_val + increment + end + next_val ∈ interval && return next_val + throw(BoundsError(interval, next_val)) +end + +function Base.maximum(interval::AbstractInterval{T,L,R}; increment=nothing) where {T,L,R} + return R === Unbounded ? typemax(T) : last(interval) +end + +function Base.maximum(interval::AbstractInterval{T,L,Open}; increment=eps(T)) where {T,L} + isempty(interval) && throw(BoundsError(interval, 0)) + max_val = last(interval) - increment + # Since intervals can't have NaN, we can just use !isfinite to check if infinite + !isfinite(max_val) && return typemax(T) + max_val ∈ interval && return max_val + throw(BoundsError(interval, max_val)) +end + +function Base.maximum(interval::AbstractInterval{T,L,Open}) where {T<:Integer,L} + return maximum(interval, increment=one(T)) +end + +function Base.maximum(interval::AbstractInterval{T,L,Open}; increment=nothing) where {T<:AbstractFloat,L} + isempty(interval) && throw(BoundsError(interval, 0)) + max_val = last(interval) + next_val = if !isfinite(max_val) || increment === nothing + prevfloat(max_val) + else + max_val - increment + end + next_val ∈ interval && return next_val + throw(BoundsError(interval, next_val)) +end + ##### CONVERSION ##### # Allows an interval to be converted to a scalar when the set contained by the interval only @@ -201,6 +260,7 @@ end ##### DISPLAY ##### + function Base.show(io::IO, interval::Interval{T,L,R}) where {T,L,R} if get(io, :compact, false) print(io, interval) diff --git a/src/isfinite.jl b/src/isfinite.jl index e811d831..a0ea15c8 100644 --- a/src/isfinite.jl +++ b/src/isfinite.jl @@ -2,3 +2,4 @@ # `Char` and `Period` as well as other types. isfinite(x) = iszero(x - x) isfinite(x::Real) = Base.isfinite(x) +isfinite(x::Union{Type{T}, T}) where T<:TimeType = Base.isfinite(x) diff --git a/test/anchoredinterval.jl b/test/anchoredinterval.jl index a3c7ad82..dfe78875 100644 --- a/test/anchoredinterval.jl +++ b/test/anchoredinterval.jl @@ -166,6 +166,8 @@ using Intervals: Bounded, Ending, Beginning, canonicalize, isunbounded @test first(interval) == DateTime(2016, 8, 11, 1, 45) @test last(interval) == dt + @test minimum(interval) == first(interval) + @test maximum(interval) == last(interval) @test bounds_types(interval) == (Closed, Closed) @test span(interval) == -P @@ -175,6 +177,8 @@ using Intervals: Bounded, Ending, Beginning, canonicalize, isunbounded @test first(interval) == Date(2016, 8, 11) @test last(interval) == Date(2016, 8, 12) + @test_throws BoundsError minimum(interval, increment=Day(1)) + @test_throws BoundsError maximum(interval, increment=Day(1)) @test bounds_types(interval) == (Open, Open) @test span(interval) == P @@ -187,6 +191,8 @@ using Intervals: Bounded, Ending, Beginning, canonicalize, isunbounded interval = AnchoredInterval{Day(1)}(startpoint) @test first(interval) == startpoint @test last(interval) == ZonedDateTime(2018, 3, 12, tz"America/Winnipeg") + @test minimum(interval) == startpoint + @test maximum(interval, increment=Hour(1)) == last(interval) - Hour(1) @test span(interval) == Day(1) endpoint = ZonedDateTime(2018, 11, 4, 2, tz"America/Winnipeg") @@ -197,24 +203,32 @@ using Intervals: Bounded, Ending, Beginning, canonicalize, isunbounded interval = AnchoredInterval{Day(1)}(startpoint) @test first(interval) == startpoint @test last(interval) == ZonedDateTime(2018, 11, 5, tz"America/Winnipeg") + @test minimum(interval) == startpoint + @test maximum(interval, increment=Millisecond(1)) == last(interval) - Millisecond(1) @test span(interval) == Day(1) endpoint = ZonedDateTime(2020, 3, 9, 2, tz"America/Winnipeg") interval = AnchoredInterval{Day(-1)}(endpoint) @test_throws NonExistentTimeError first(interval) @test last(interval) == endpoint + @test_throws NonExistentTimeError minimum(interval, increment=Hour(1)) + @test maximum(interval) == endpoint @test span(interval) == Day(1) # Non-period AnchoredIntervals interval = AnchoredInterval{-10}(10) @test first(interval) == 0 @test last(interval) == 10 + @test minimum(interval) == 1 + @test maximum(interval) == 10 @test bounds_types(interval) == (Open, Closed) @test span(interval) == 10 interval = AnchoredInterval{25}('a') @test first(interval) == 'a' @test last(interval) == 'z' + @test minimum(interval) == 'a' + @test maximum(interval, increment=1) == 'y' @test bounds_types(interval) == (Closed, Open) @test span(interval) == 25 end diff --git a/test/interval.jl b/test/interval.jl index b3d82fac..d693c61e 100644 --- a/test/interval.jl +++ b/test/interval.jl @@ -123,7 +123,6 @@ for (a, b, _) in test_values for (L, R) in BOUND_PERMUTATIONS interval = Interval{L, R}(a, b) - @test first(interval) == a @test last(interval) == b @test span(interval) == b - a @@ -144,6 +143,141 @@ @test span(interval) == Hour(3) end + @testset "maximum/minimum" begin + # Helper functions that manage the value we should be expecting from min and max. + function _min_val_helper(interval, a, unit) + t = eltype(interval) + # If the interal is empty, min is nothing + isempty(interval) && return nothing + + # If a is in the interval, it is closed/unbounded and min is the first value. + # If a is nothing then it is unbounded and min is typemin(T) + a === nothing && return typemin(t) + a ∈ interval && return first(interval) + + # From this point on, b ∉ interval so the bound is Open + # Also, if a is infinite we return typemin + # If it's an abstractfloat, we can't return just typemin since typemin IS Inf and + # since the bound is open at this point, Inf ∉ interval So we return the one after INF + !Intervals.isfinite(a) && t <: AbstractFloat && return nextfloat(a) + !Intervals.isfinite(a) && return typemin(t) + + f = first(interval) + nv = if t <: AbstractFloat && unit === nothing + nextfloat(f) + else + f + unit + end + + nv ∈ interval && return nv + + # If we get to this point, the min/max functions throw a DomainError + # Since we want our tests to be predictable, we will not throw an error in this helper. + end + + function _max_val_helper(interval, b, unit) + t = eltype(interval) + # If the interal is empty, min is nothing + isempty(interval) && return nothing + + # If a is in the interval, it is closed/unbounded and min is the first value. + # If a is nothing then it is unbounded and min is typemin(T) + b === nothing && return typemax(t) + b ∈ interval && return last(interval) + + # From this point on, b ∉ interval so the bound is Open + # Also, if a is infinite we return typemin + # If it's an abstractfloat, we can't return just typemin since typemin IS Inf and + # since the bound is open at this point, Inf ∉ interval So we return the one after INF + !isfinite(b) && t <: AbstractFloat && return prevfloat(b) + !isfinite(b) && return typemax(t) + + l = last(interval) + nv = if t <: AbstractFloat && unit === nothing + prevfloat(l) + else + l - unit + end + + nv ∈ interval && return nv + + # If we get to this point, the min/max functions throw a DomainError + # Since we want our tests to be predictable, we will not throw an error in this helper. + end + @testset "bounded intervals" begin + bounded_test_vals = [ + #test nextfloat and prevfloat + (-10.0, 10.0, nothing), + (-Inf, Inf, nothing), + + ('c', 'x', 2), + (Date(2004, 2, 13), Date(2020, 3, 13), Day(1)), + ] + for (a, b, unit) in append!(bounded_test_vals, test_values) + for (L, R) in BOUND_PERMUTATIONS + interval = Interval{L, R}(a, b) + + mi = _min_val_helper(interval, a, unit) + ma = _max_val_helper(interval, b, unit) + + @test minimum(interval; increment=unit) == mi + @test maximum(interval; increment=unit) == ma + end + end + end + + @testset "unbounded intervals" begin + unbounded_test_values = [ + # one side unbounded with different types + (Interval{Open,Unbounded}(-10, nothing), 1), + (Interval{Unbounded,Closed}(nothing, 1.0), 0.01), + (Interval{Unbounded,Open}(nothing, 'z'), 1), + (Interval{Closed,Unbounded}(Date(2013, 2, 13), nothing), Day(1)), + (Interval{Open,Unbounded}(DateTime(2016, 8, 11, 0, 30), nothing), Millisecond(1)), + # both sides unbounded different types + (Interval{Int}(nothing, nothing), 1), + (Interval{Float64}(nothing, nothing), 0.01), + (Interval{Char}(nothing , nothing), 1), + (Interval{Day}(nothing, nothing), Day(1)), + (Interval{DateTime}(nothing, nothing), Millisecond(1)), + # test adding eps() with unbounded + (Interval{Open,Unbounded}(-10.0, nothing), nothing), + (Interval{Unbounded,Open}(nothing, 10.0), nothing), + # test infinity + (Interval{Open,Unbounded}(-Inf, nothing), nothing), + (Interval{Unbounded,Open}(nothing, Inf), nothing), + ] + for (interval, unit) in unbounded_test_values + a, b = first(interval), last(interval) + + mi = _min_val_helper(interval, a, unit) + ma = _max_val_helper(interval, b, unit) + + @test minimum(interval; increment=unit) == mi + @test maximum(interval; increment=unit) == ma + @test_throws DomainError span(interval) + + end + end + @testset "bounds errors in min/max" begin + error_test_vals = [ + # empty intervals + (Interval{Open,Open}(-10, -10), 1), + (Interval{Open,Open}(0.0, 0.0), 60), + (Interval{Open,Open}(Date(2013, 2, 13), Date(2013, 2, 13)), Day(1)), + (Interval{Open,Open}(DateTime(2016, 8, 11, 0, 30), DateTime(2016, 8, 11, 0, 30)), Day(1)), + # increment too large + (Interval{Open,Open}(-10, 15), 60), + (Interval{Open,Open}(0.0, 25), 60.0), + (Interval{Open,Open}(Date(2013, 2, 13), Date(2013, 2, 14)), Day(5)), + (Interval{Open,Open}(DateTime(2016, 8, 11, 0, 30), DateTime(2016, 8, 11, 5, 30)), Day(5)), + ] + for (interval, unit) in error_test_vals + @test_throws BoundsError minimum(interval; increment=unit) + @test_throws BoundsError maximum(interval; increment=unit) + end + end + end @testset "display" begin interval = Interval{Open, Open}(1, 2) @test string(interval) == "(1 .. 2)"