diff --git a/doc/source/reference/arrays.rst b/doc/source/reference/arrays.rst index bf9520c54040d..7f464bf952bfb 100644 --- a/doc/source/reference/arrays.rst +++ b/doc/source/reference/arrays.rst @@ -295,6 +295,7 @@ Properties Interval.closed Interval.closed_left Interval.closed_right + Interval.is_empty Interval.left Interval.length Interval.mid @@ -331,6 +332,7 @@ A collection of intervals may be stored in an :class:`arrays.IntervalArray`. arrays.IntervalArray.closed arrays.IntervalArray.mid arrays.IntervalArray.length + arrays.IntervalArray.is_empty arrays.IntervalArray.is_non_overlapping_monotonic arrays.IntervalArray.from_arrays arrays.IntervalArray.from_tuples diff --git a/doc/source/reference/indexing.rst b/doc/source/reference/indexing.rst index 65860eb5c2f51..576f734d517aa 100644 --- a/doc/source/reference/indexing.rst +++ b/doc/source/reference/indexing.rst @@ -254,6 +254,7 @@ IntervalIndex components IntervalIndex.closed IntervalIndex.length IntervalIndex.values + IntervalIndex.is_empty IntervalIndex.is_non_overlapping_monotonic IntervalIndex.is_overlapping IntervalIndex.get_loc diff --git a/doc/source/whatsnew/v0.25.0.rst b/doc/source/whatsnew/v0.25.0.rst index 77426e950798c..8f6cd586c29b3 100644 --- a/doc/source/whatsnew/v0.25.0.rst +++ b/doc/source/whatsnew/v0.25.0.rst @@ -212,6 +212,7 @@ Other enhancements - :class:`pandas.offsets.BusinessHour` supports multiple opening hours intervals (:issue:`15481`) - :func:`read_excel` can now use ``openpyxl`` to read Excel files via the ``engine='openpyxl'`` argument. This will become the default in a future release (:issue:`11499`) - :func:`pandas.io.excel.read_excel` supports reading OpenDocument tables. Specify ``engine='odf'`` to enable. Consult the :ref:`IO User Guide ` for more details (:issue:`9070`) +- :class:`Interval`, :class:`IntervalIndex`, and :class:`~arrays.IntervalArray` have gained an :attr:`~Interval.is_empty` attribute denoting if the given interval(s) are empty (:issue:`27219`) .. _whatsnew_0250.api_breaking: diff --git a/pandas/_libs/interval.pyx b/pandas/_libs/interval.pyx index 6c1df419865ed..3c7ec70fb1f88 100644 --- a/pandas/_libs/interval.pyx +++ b/pandas/_libs/interval.pyx @@ -107,6 +107,59 @@ cdef class IntervalMixin: """Return the length of the Interval""" return self.right - self.left + @property + def is_empty(self): + """ + Indicates if an interval is empty, meaning it contains no points. + + .. versionadded:: 0.25.0 + + Returns + ------- + bool or ndarray + A boolean indicating if a scalar :class:`Interval` is empty, or a + boolean ``ndarray`` positionally indicating if an ``Interval`` in + an :class:`~arrays.IntervalArray` or :class:`IntervalIndex` is + empty. + + Examples + -------- + An :class:`Interval` that contains points is not empty: + + >>> pd.Interval(0, 1, closed='right').is_empty + False + + An ``Interval`` that does not contain any points is empty: + + >>> pd.Interval(0, 0, closed='right').is_empty + True + >>> pd.Interval(0, 0, closed='left').is_empty + True + >>> pd.Interval(0, 0, closed='neither').is_empty + True + + An ``Interval`` that contains a single point is not empty: + + >>> pd.Interval(0, 0, closed='both').is_empty + False + + An :class:`~arrays.IntervalArray` or :class:`IntervalIndex` returns a + boolean ``ndarray`` positionally indicating if an ``Interval`` is + empty: + + >>> ivs = [pd.Interval(0, 0, closed='neither'), + ... pd.Interval(1, 2, closed='neither')] + >>> pd.arrays.IntervalArray(ivs).is_empty + array([ True, False]) + + Missing values are not considered empty: + + >>> ivs = [pd.Interval(0, 0, closed='neither'), np.nan] + >>> pd.IntervalIndex(ivs).is_empty + array([ True, False]) + """ + return (self.right == self.left) & (self.closed != 'both') + def _check_closed_matches(self, other, name='other'): """Check if the closed attribute of `other` matches. diff --git a/pandas/core/arrays/interval.py b/pandas/core/arrays/interval.py index aa56d99d298f4..cf8ca25857f4e 100644 --- a/pandas/core/arrays/interval.py +++ b/pandas/core/arrays/interval.py @@ -66,6 +66,7 @@ closed mid length +is_empty is_non_overlapping_monotonic %(extra_attributes)s\ diff --git a/pandas/tests/arrays/interval/test_interval.py b/pandas/tests/arrays/interval/test_interval.py index 34de36b4f6665..4a7962d88a44e 100644 --- a/pandas/tests/arrays/interval/test_interval.py +++ b/pandas/tests/arrays/interval/test_interval.py @@ -2,7 +2,9 @@ import pytest import pandas as pd -from pandas import Index, Interval, IntervalIndex, date_range, timedelta_range +from pandas import ( + Index, Interval, IntervalIndex, Timedelta, Timestamp, date_range, + timedelta_range) from pandas.core.arrays import IntervalArray import pandas.util.testing as tm @@ -23,6 +25,23 @@ def left_right_dtypes(request): return request.param +class TestAttributes: + @pytest.mark.parametrize('left, right', [ + (0, 1), + (Timedelta('0 days'), Timedelta('1 day')), + (Timestamp('2018-01-01'), Timestamp('2018-01-02')), + pytest.param(Timestamp('2018-01-01', tz='US/Eastern'), + Timestamp('2018-01-02', tz='US/Eastern'), + marks=pytest.mark.xfail(strict=True, reason='GH 27011'))]) + @pytest.mark.parametrize('constructor', [IntervalArray, IntervalIndex]) + def test_is_empty(self, constructor, left, right, closed): + # GH27219 + tuples = [(left, left), (left, right), np.nan] + expected = np.array([closed != 'both', False, False]) + result = constructor.from_tuples(tuples, closed=closed).is_empty + tm.assert_numpy_array_equal(result, expected) + + class TestMethods: @pytest.mark.parametrize('new_closed', [ diff --git a/pandas/tests/scalar/interval/test_interval.py b/pandas/tests/scalar/interval/test_interval.py index e19ff82b9b267..6645244318776 100644 --- a/pandas/tests/scalar/interval/test_interval.py +++ b/pandas/tests/scalar/interval/test_interval.py @@ -94,6 +94,24 @@ def test_length_timestamp(self, tz, left, right, expected): expected = Timedelta(expected) assert result == expected + @pytest.mark.parametrize('left, right', [ + (0, 1), + (Timedelta('0 days'), Timedelta('1 day')), + (Timestamp('2018-01-01'), Timestamp('2018-01-02')), + (Timestamp('2018-01-01', tz='US/Eastern'), + Timestamp('2018-01-02', tz='US/Eastern'))]) + def test_is_empty(self, left, right, closed): + # GH27219 + # non-empty always return False + iv = Interval(left, right, closed) + assert iv.is_empty is False + + # same endpoint is empty except when closed='both' (contains one point) + iv = Interval(left, left, closed) + result = iv.is_empty + expected = closed != 'both' + assert result is expected + @pytest.mark.parametrize('left, right', [ ('a', 'z'), (('a', 'b'), ('c', 'd')),