Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add overlap functionality to Timex.Interval #493

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
65 changes: 65 additions & 0 deletions lib/interval/interval.ex
Expand Up @@ -382,6 +382,71 @@ defmodule Timex.Interval do
def max(%__MODULE__{until: until, right_open: false}), do: until
def max(%__MODULE__{until: until}), do: Timex.shift(until, microseconds: -1)

@doc """
Returns an Interval representing the intersection between two intervals.
If the intervals do not overlap, return {:error, :no_overlap_interval}.
If the intervals overlap at a single instant (regardless of open/closed
bounds), also return {:error, :no_overlap_interval}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it should return 0 in the case of no overlap rather than an error? Two intervals not overlapping doesn't seem like an error to me.

"""
@spec overlap(__MODULE__.t(), __MODULE__.t()) :: __MODULE__.t() | {:error, :no_overlap_interval}
def overlap(%__MODULE__{} = a, %__MODULE__{} = b) do
{from, left_open} = start_of_overlap(a, b)
{until, right_open} = end_of_overlap(a, b)

case new(from: from, until: until, left_open: left_open, right_open: right_open) do
{:error, _} -> {:error, :no_overlap_interval}
interval -> interval
end
end

@doc """
Take the later start time of the two overlapping intervals,
and the left_open value of that interval.
"""
defp start_of_overlap(%__MODULE__{} = a, %__MODULE__{} = b) do
cond do
Timex.equal?(a.from, b.from) -> {a.from, determine_bound(a.left_open, b.left_open)}
Timex.before?(a.from, b.from) -> {b.from, b.left_open}
true -> {a.from, a.left_open}
end
end

@doc """
Take the earlier end time of the 2 overlapping intervals,
and the right_open value of that interval.
"""
defp end_of_overlap(%__MODULE__{} = a, %__MODULE__{} = b) do
cond do
Timex.equal?(a.until, b.until) -> {a.until, determine_bound(a.right_open, b.right_open)}
Timex.before?(a.until, b.until) -> {a.until, a.right_open}
true -> {b.until, b.right_open}
end
end

@doc """
When calculating overlap, if two intervals share a `from` (or `until`), the overlap
interval should have a bound matching the "inner" interval (eg: if either interval has
an open bound, the overlap should have an open bound).

## Example:

[----) <- Interval a
(-------] <- Interval b
(----) <- overlap interval (left_open: true)

Interval a and b have the same `from` value.
Interval a has `left_open: false
Interval b has `left_open: true`

The resulting overlap interval should have `left_open: true`

To determine the appropriate bound, if both intervals have a 'closed' bound on the matching
`from` or `until`, then the resulting overlap interval should have a 'closed' bound. In all
other cases, the overlap interval should have an 'open' bound.
"""
defp determine_bound(false, false), do: false
defp determine_bound(_, _), do: true

defimpl Enumerable do
alias Timex.Interval

Expand Down
103 changes: 103 additions & 0 deletions test/interval_test.exs
Expand Up @@ -196,6 +196,109 @@ defmodule IntervalTests do
end
end

describe "overlap" do
test "non-overlapping intervals" do
a = Interval.new(from: ~N[2017-01-02 15:00:00], until: ~N[2017-01-02 15:15:00])
b = Interval.new(from: ~N[2017-01-02 15:30:00], until: ~N[2017-01-02 15:45:00])

assert {:error, _} = Interval.overlap(a, b)
end

test "non-overlapping back-to-back intervals" do
a = Interval.new(from: ~N[2017-01-02 15:00:00], until: ~N[2017-01-02 15:15:00], right_open: true)
b = Interval.new(from: ~N[2017-01-02 15:15:00], until: ~N[2017-01-02 15:30:00])

assert {:error, _} = Interval.overlap(a, b)
end

test "overlapping at single instant with closed bounds" do
a = Interval.new(from: ~N[2017-01-02 15:00:00], until: ~N[2017-01-02 15:15:00], right_open: false)
b = Interval.new(from: ~N[2017-01-02 15:15:00], until: ~N[2017-01-02 15:30:00], left_open: false)

assert {:error, _} = Interval.overlap(a, b)
end

test "first subset of second" do
a = Interval.new(from: ~N[2017-01-02 15:00:00], until: ~N[2017-01-02 15:45:00])
b = Interval.new(from: ~N[2017-01-02 15:20:00], until: ~N[2017-01-02 15:30:00])

assert Interval.new(from: ~N[2017-01-02 15:20:00], until: ~N[2017-01-02 15:30:00]) == Interval.overlap(a, b)
assert Interval.new(from: ~N[2017-01-02 15:20:00], until: ~N[2017-01-02 15:30:00]) == Interval.overlap(b, a)
end

test "partially overlapping" do
a = Interval.new(from: ~N[2017-01-02 15:00:00], until: ~N[2017-01-02 15:15:00])
b = Interval.new(from: ~N[2017-01-02 15:10:00], until: ~N[2017-01-02 15:30:00])

assert Interval.new(from: ~N[2017-01-02 15:10:00], until: ~N[2017-01-02 15:15:00]) == Interval.overlap(a, b)
assert Interval.new(from: ~N[2017-01-02 15:10:00], until: ~N[2017-01-02 15:15:00]) == Interval.overlap(b, a)
end

test "overlapping across hours" do
a = Interval.new(from: ~N[2017-01-02 14:50:00], until: ~N[2017-01-02 15:15:00])
b = Interval.new(from: ~N[2017-01-02 14:55:00], until: ~N[2017-01-02 15:30:00])

assert Interval.new(from: ~N[2017-01-02 14:55:00], until: ~N[2017-01-02 15:15:00]) == Interval.overlap(a, b)
assert Interval.new(from: ~N[2017-01-02 14:55:00], until: ~N[2017-01-02 15:15:00]) == Interval.overlap(b, a)
end

test "overlapping across days" do
a = Interval.new(from: ~N[2017-01-15 23:40:00], until: ~N[2017-01-16 00:10:00])
b = Interval.new(from: ~N[2017-01-15 23:50:00], until: ~N[2017-01-16 00:20:00])

assert Interval.new(from: ~N[2017-01-15 23:50:00], until: ~N[2017-01-16 00:10:00]) == Interval.overlap(a, b)
assert Interval.new(from: ~N[2017-01-15 23:50:00], until: ~N[2017-01-16 00:10:00]) == Interval.overlap(b, a)
end

test "overlapping across months" do
a = Interval.new(from: ~N[2017-06-30 23:40:00], until: ~N[2017-07-01 00:10:00])
b = Interval.new(from: ~N[2017-06-30 23:50:00], until: ~N[2017-07-01 00:20:00])

assert Interval.new(from: ~N[2017-06-30 23:50:00], until: ~N[2017-07-01 00:10:00]) == Interval.overlap(a, b)
assert Interval.new(from: ~N[2017-06-30 23:50:00], until: ~N[2017-07-01 00:10:00]) == Interval.overlap(b, a)
end

test "overlapping across years" do
a = Interval.new(from: ~N[2016-12-31 23:30:00], until: ~N[2017-01-01 00:30:00])
b = Interval.new(from: ~N[2016-12-31 23:45:00], until: ~N[2017-01-01 00:15:00])

assert Interval.new(from: ~N[2016-12-31 23:45:00], until: ~N[2017-01-01 00:15:00]) == Interval.overlap(a, b)
assert Interval.new(from: ~N[2016-12-31 23:45:00], until: ~N[2017-01-01 00:15:00]) == Interval.overlap(b, a)
end

test "shared from/until with different openness" do
a = Interval.new(from: ~N[2017-01-02 15:00:00], until: ~N[2017-01-02 15:15:00], left_open: true, right_open: false)
b = Interval.new(from: ~N[2017-01-02 15:00:00], until: ~N[2017-01-02 15:15:00], left_open: false, right_open: true)

assert Interval.new(from: ~N[2017-01-02 15:00:00], until: ~N[2017-01-02 15:15:00], right_open: true, left_open: true) == Interval.overlap(a, b)
assert Interval.new(from: ~N[2017-01-02 15:00:00], until: ~N[2017-01-02 15:15:00], right_open: true, left_open: true) == Interval.overlap(b, a)
end

test "left_open: true, right_open: true" do
a = Interval.new(from: ~N[2017-01-02 15:00:00], until: ~N[2017-01-02 15:15:00], right_open: true)
b = Interval.new(from: ~N[2017-01-02 15:10:00], until: ~N[2017-01-02 15:30:00], left_open: true)

assert Interval.new(from: ~N[2017-01-02 15:10:00], until: ~N[2017-01-02 15:15:00], right_open: true, left_open: true) == Interval.overlap(a, b)
assert Interval.new(from: ~N[2017-01-02 15:10:00], until: ~N[2017-01-02 15:15:00], right_open: true, left_open: true) == Interval.overlap(b, a)
end

test "left_open: true, right_open: false" do
a = Interval.new(from: ~N[2017-01-02 15:00:00], until: ~N[2017-01-02 15:15:00], right_open: false)
b = Interval.new(from: ~N[2017-01-02 15:10:00], until: ~N[2017-01-02 15:30:00], left_open: true)

assert Interval.new(from: ~N[2017-01-02 15:10:00], until: ~N[2017-01-02 15:15:00], right_open: false, left_open: true) == Interval.overlap(a, b)
assert Interval.new(from: ~N[2017-01-02 15:10:00], until: ~N[2017-01-02 15:15:00], right_open: false, left_open: true) == Interval.overlap(b, a)
end

test "left_open: false, right_open: false" do
a = Interval.new(from: ~N[2017-01-02 15:00:00], until: ~N[2017-01-02 15:15:00], right_open: false)
b = Interval.new(from: ~N[2017-01-02 15:10:00], until: ~N[2017-01-02 15:30:00], left_open: false)

assert Interval.new(from: ~N[2017-01-02 15:10:00], until: ~N[2017-01-02 15:15:00], right_open: false, left_open: false) == Interval.overlap(a, b)
assert Interval.new(from: ~N[2017-01-02 15:10:00], until: ~N[2017-01-02 15:15:00], right_open: false, left_open: false) == Interval.overlap(b, a)
end
end

describe "contains?/2" do
test "non-overlapping" do
earlier = Interval.new(from: ~D[2018-01-01], until: ~D[2018-01-04])
Expand Down