diff --git a/doc/source/whatsnew/v0.25.0.rst b/doc/source/whatsnew/v0.25.0.rst index f7faeea7a646f..e4889918041c2 100644 --- a/doc/source/whatsnew/v0.25.0.rst +++ b/doc/source/whatsnew/v0.25.0.rst @@ -617,13 +617,12 @@ Strings - Improved error message when passing :class:`Series` of wrong dtype to :meth:`Series.str.cat` (:issue:`22722`) - - Interval ^^^^^^^^ - Construction of :class:`Interval` is restricted to numeric, :class:`Timestamp` and :class:`Timedelta` endpoints (:issue:`23013`) - Fixed bug in :class:`Series`/:class:`DataFrame` not displaying ``NaN`` in :class:`IntervalIndex` with missing values (:issue:`25984`) -- +- :class:Interval now refuses to create intervals like `[0,0)` or `(0,0]` (:issue:`26893`) Indexing ^^^^^^^^ diff --git a/pandas/_libs/interval.pyx b/pandas/_libs/interval.pyx index 6c1df419865ed..222e159f2cf8a 100644 --- a/pandas/_libs/interval.pyx +++ b/pandas/_libs/interval.pyx @@ -255,6 +255,9 @@ cdef class Interval(IntervalMixin): raise ValueError(msg) if not left <= right: raise ValueError('left side of interval must be <= right side') + if left == right and closed not in ('both', 'neither'): + msg='both/neither sides must be closed when left == right' + raise ValueError(msg) if (isinstance(left, Timestamp) and not tz_compare(left.tzinfo, right.tzinfo)): # GH 18538 diff --git a/pandas/core/reshape/tile.py b/pandas/core/reshape/tile.py index 8c29bdc2a974c..2f7f525dcac6e 100644 --- a/pandas/core/reshape/tile.py +++ b/pandas/core/reshape/tile.py @@ -476,9 +476,9 @@ def _format_labels(bins, precision, right=True, if right and include_lowest: # we will adjust the left hand side by precision to # account that we are all right closed - v = adjust(labels[0].left) + v = adjust(labels.left[0]) - i = IntervalIndex([Interval(v, labels[0].right, closed='right')]) + i = IntervalIndex([Interval(v, labels.right[0], closed='right')]) labels = i.append(labels[1:]) return labels diff --git a/pandas/tests/arrays/interval/test_interval.py b/pandas/tests/arrays/interval/test_interval.py index 34de36b4f6665..20ad90726be11 100644 --- a/pandas/tests/arrays/interval/test_interval.py +++ b/pandas/tests/arrays/interval/test_interval.py @@ -65,3 +65,30 @@ def test_repr_matches(): a = repr(idx) b = repr(idx.values) assert a.replace("Index", "Array") == b + + +def test_point_interval_illegal(): + match = "both/neither sides must be closed when left == right" + for closed in ('left', 'right'): + with pytest.raises(ValueError, match=match): + pd.Interval(0, 0, closed) + + +@pytest.mark.parametrize('left_interval, right_interval, result', + [ + ((0, 1, "left"), (0, 0, "neither"), False), + ((0, 1, "left"), (0, 0, "both"), True), + ((0, 1, "right"), (1, 1, "neither"), False), + ((0, 1, "right"), (1, 1, "both"), True), + ((0, 1, "both"), (0, 0, "neither"), False), + ((0, 1, "both"), (0, 0, "neither"), False), + ((0, 1, "neither"), (1, 1, "both"), False), + ((0, 1, "neither"), (1, 1, "both"), False), + ]) +def test_point_interval(left_interval, right_interval, result): + pd.Interval(0, 0, 'neither') # no exception + pd.Interval(0, 0, 'both') # no exception + + a = Interval(*left_interval) + b = Interval(*right_interval) + assert result == a.overlaps(b) diff --git a/pandas/tests/indexes/interval/test_interval.py b/pandas/tests/indexes/interval/test_interval.py index b2f409837344a..036b55ceda4b0 100644 --- a/pandas/tests/indexes/interval/test_interval.py +++ b/pandas/tests/indexes/interval/test_interval.py @@ -73,7 +73,7 @@ def test_properties(self, closed): tm.assert_numpy_array_equal(np.asarray(index), expected) @pytest.mark.parametrize('breaks', [ - [1, 1, 2, 5, 15, 53, 217, 1014, 5335, 31240, 201608], + [1, 2, 5, 15, 53, 217, 1014, 5335, 31240, 201608], [-np.inf, -100, -10, 0.5, 1, 1.5, 3.8, 101, 202, np.inf], pd.to_datetime(['20170101', '20170202', '20170303', '20170404']), pd.to_timedelta(['1ns', '2ms', '3s', '4M', '5H', '6D'])]) @@ -90,6 +90,22 @@ def test_length(self, closed, breaks): expected = Index(iv.length if notna(iv) else iv for iv in index) tm.assert_index_equal(result, expected) + @pytest.mark.parametrize('breaks', [ + [1, 1, 2, 2]]) + @pytest.mark.parametrize('closed', ['neither', 'both']) + def test_length_with_point_intervals(self, closed, breaks): + # GH 18789 + index = IntervalIndex.from_breaks(breaks, closed=closed) + result = index.length + expected = Index(iv.length for iv in index) + tm.assert_index_equal(result, expected) + + # with NA + index = index.insert(1, np.nan) + result = index.length + expected = Index(iv.length if notna(iv) else iv for iv in index) + tm.assert_index_equal(result, expected) + def test_with_nans(self, closed): index = self.create_index(closed=closed) assert index.hasnans is False diff --git a/pandas/tests/scalar/interval/test_interval.py b/pandas/tests/scalar/interval/test_interval.py index e19ff82b9b267..407b332de28f2 100644 --- a/pandas/tests/scalar/interval/test_interval.py +++ b/pandas/tests/scalar/interval/test_interval.py @@ -77,7 +77,7 @@ def test_hash(self, interval): (Timedelta('5S'), Timedelta('1H'), Timedelta('59M55S'))]) def test_length(self, left, right, expected): # GH 18789 - iv = Interval(left, right) + iv = Interval(left, right, 'both') result = iv.length assert result == expected @@ -89,7 +89,7 @@ def test_length(self, left, right, expected): @pytest.mark.parametrize('tz', (None, 'UTC', 'CET', 'US/Eastern')) def test_length_timestamp(self, tz, left, right, expected): # GH 18789 - iv = Interval(Timestamp(left, tz=tz), Timestamp(right, tz=tz)) + iv = Interval(Timestamp(left, tz=tz), Timestamp(right, tz=tz), 'both') result = iv.length expected = Timedelta(expected) assert result == expected