Repository: pandas-dev/pandas

Issue:
IntervalIndex is_non_overlapping_monotonic is incorrect when endpoints are shared
#### Code Sample, a copy-pastable example if possible

```python
In [2]: idx = pd.IntervalIndex.from_breaks(range(4), closed='both')

In [3]: idx
Out[3]:
IntervalIndex([[0, 1], [1, 2], [2, 3]]
              closed='both',
              dtype='interval[int64]')

In [4]: idx.is_non_overlapping_monotonic
Out[4]: True
```
#### Problem description
The intervals have an overlap in the endpoints, 1 is shared among `[0, 1], [1, 2]` and 2 is shared among `[1, 2], [2, 3]`, but `is_non_overlapping_monotonic` returns `True` indicating that the intervals are non-overlapping.

#### Expected Output
I'd expected `is_non_overlapping_monotonic` to return `False`.  

If my interpretation of "overlapping" is incorrect, and in this context means an interval overlap (i.e. not a point/has positive length), then it'd be nice if this were more explicitly stated.

#### Output of ``pd.show_versions()``

<details>

INSTALLED VERSIONS
------------------
commit: None
python: 3.5.2.final.0
python-bits: 64
OS: Windows
OS-release: 10
machine: AMD64
processor: Intel64 Family 6 Model 78 Stepping 3, GenuineIntel
byteorder: little
LC_ALL: None
LANG: None
LOCALE: None.None

pandas: 0.20.1
pytest: 3.0.7
pip: 9.0.1
setuptools: 34.4.1
Cython: 0.25.2
numpy: 1.13.0rc2
scipy: 0.18.1
xarray: 0.9.5
IPython: 5.3.0
sphinx: None
patsy: 0.4.1
dateutil: 2.6.0
pytz: 2017.2
blosc: 1.4.4
bottleneck: 1.2.0
tables: 3.4.2
numexpr: 2.6.1
feather: None
matplotlib: 2.0.2
openpyxl: 2.4.0
xlrd: 1.0.0
xlwt: None
xlsxwriter: None
lxml: 3.7.3
bs4: None
html5lib: None
sqlalchemy: 1.1.10
pymysql: None
psycopg2: None
jinja2: 2.9.6
s3fs: None
pandas_gbq: 0.1.6
pandas_datareader: 0.4.0
</details>

IntervalIndex is_non_overlapping_monotonic is incorrect when endpoints are shared
#### Code Sample, a copy-pastable example if possible

```python
In [2]: idx = pd.IntervalIndex.from_breaks(range(4), closed='both')

In [3]: idx
Out[3]:
IntervalIndex([[0, 1], [1, 2], [2, 3]]
              closed='both',
              dtype='interval[int64]')

In [4]: idx.is_non_overlapping_monotonic
Out[4]: True
```
#### Problem description
The intervals have an overlap in the endpoints, 1 is shared among `[0, 1], [1, 2]` and 2 is shared among `[1, 2], [2, 3]`, but `is_non_overlapping_monotonic` returns `True` indicating that the intervals are non-overlapping.

#### Expected Output
I'd expected `is_non_overlapping_monotonic` to return `False`.  

If my interpretation of "overlapping" is incorrect, and in this context means an interval overlap (i.e. not a point/has positive length), then it'd be nice if this were more explicitly stated.

#### Output of ``pd.show_versions()``

<details>

INSTALLED VERSIONS
------------------
commit: None
python: 3.5.2.final.0
python-bits: 64
OS: Windows
OS-release: 10
machine: AMD64
processor: Intel64 Family 6 Model 78 Stepping 3, GenuineIntel
byteorder: little
LC_ALL: None
LANG: None
LOCALE: None.None

pandas: 0.20.1
pytest: 3.0.7
pip: 9.0.1
setuptools: 34.4.1
Cython: 0.25.2
numpy: 1.13.0rc2
scipy: 0.18.1
xarray: 0.9.5
IPython: 5.3.0
sphinx: None
patsy: 0.4.1
dateutil: 2.6.0
pytz: 2017.2
blosc: 1.4.4
bottleneck: 1.2.0
tables: 3.4.2
numexpr: 2.6.1
feather: None
matplotlib: 2.0.2
openpyxl: 2.4.0
xlrd: 1.0.0
xlwt: None
xlsxwriter: None
lxml: 3.7.3
bs4: None
html5lib: None
sqlalchemy: 1.1.10
pymysql: None
psycopg2: None
jinja2: 2.9.6
s3fs: None
pandas_gbq: 0.1.6
pandas_datareader: 0.4.0
</details>



Discussion:
At first glance, I think this amounts to using strict inequalities when `closed='both'`, and using the existing inclusive inequality code for [`is_non_overlapping_monotonic`](https://github.com/pandas-dev/pandas/blob/master/pandas/core/indexes/interval.py#L555-L560) otherwise.
``closed='both'`` is not very well tested. Almost all things exercise ``closed='right'``, which is the default. So for certain could add some tests.
At first glance, I think this amounts to using strict inequalities when `closed='both'`, and using the existing inclusive inequality code for [`is_non_overlapping_monotonic`](https://github.com/pandas-dev/pandas/blob/master/pandas/core/indexes/interval.py#L555-L560) otherwise.
``closed='both'`` is not very well tested. Almost all things exercise ``closed='right'``, which is the default. So for certain could add some tests.

Resolving patch:
diff --git a/pandas/core/indexes/interval.py b/pandas/core/indexes/interval.py
--- a/pandas/core/indexes/interval.py
+++ b/pandas/core/indexes/interval.py
@@ -556,8 +556,17 @@ def is_non_overlapping_monotonic(self):
         # must be increasing  (e.g., [0, 1), [1, 2), [2, 3), ... )
         # or decreasing (e.g., [-1, 0), [-2, -1), [-3, -2), ...)
         # we already require left <= right
-        return ((self.right[:-1] <= self.left[1:]).all() or
-                (self.left[:-1] >= self.right[1:]).all())
+
+        # strict inequality for closed == 'both'; equality implies overlapping
+        # at a point when both sides of intervals are included
+        if self.closed == 'both':
+            return bool((self.right[:-1] < self.left[1:]).all() or
+                        (self.left[:-1] > self.right[1:]).all())
+
+        # non-strict inequality when closed != 'both'; at least one side is
+        # not included in the intervals, so equality does not imply overlapping
+        return bool((self.right[:-1] <= self.left[1:]).all() or
+                    (self.left[:-1] >= self.right[1:]).all())
 
     @Appender(_index_shared_docs['_convert_scalar_indexer'])
     def _convert_scalar_indexer(self, key, kind=None):


Test patch:
diff --git a/pandas/tests/indexes/test_interval.py b/pandas/tests/indexes/test_interval.py
--- a/pandas/tests/indexes/test_interval.py
+++ b/pandas/tests/indexes/test_interval.py
@@ -371,8 +371,9 @@ def slice_locs_cases(self, breaks):
         assert index.slice_locs(1, 1) == (1, 1)
         assert index.slice_locs(1, 2) == (1, 2)
 
-        index = IntervalIndex.from_breaks([0, 1, 2], closed='both')
-        assert index.slice_locs(1, 1) == (0, 2)
+        index = IntervalIndex.from_tuples([(0, 1), (2, 3), (4, 5)],
+                                          closed='both')
+        assert index.slice_locs(1, 1) == (0, 1)
         assert index.slice_locs(1, 2) == (0, 2)
 
     def test_slice_locs_int64(self):
@@ -681,6 +682,42 @@ def f():
 
         pytest.raises(ValueError, f)
 
+    def test_is_non_overlapping_monotonic(self):
+        # Should be True in all cases
+        tpls = [(0, 1), (2, 3), (4, 5), (6, 7)]
+        for closed in ('left', 'right', 'neither', 'both'):
+            idx = IntervalIndex.from_tuples(tpls, closed=closed)
+            assert idx.is_non_overlapping_monotonic is True
+
+            idx = IntervalIndex.from_tuples(reversed(tpls), closed=closed)
+            assert idx.is_non_overlapping_monotonic is True
+
+        # Should be False in all cases (overlapping)
+        tpls = [(0, 2), (1, 3), (4, 5), (6, 7)]
+        for closed in ('left', 'right', 'neither', 'both'):
+            idx = IntervalIndex.from_tuples(tpls, closed=closed)
+            assert idx.is_non_overlapping_monotonic is False
+
+            idx = IntervalIndex.from_tuples(reversed(tpls), closed=closed)
+            assert idx.is_non_overlapping_monotonic is False
+
+        # Should be False in all cases (non-monotonic)
+        tpls = [(0, 1), (2, 3), (6, 7), (4, 5)]
+        for closed in ('left', 'right', 'neither', 'both'):
+            idx = IntervalIndex.from_tuples(tpls, closed=closed)
+            assert idx.is_non_overlapping_monotonic is False
+
+            idx = IntervalIndex.from_tuples(reversed(tpls), closed=closed)
+            assert idx.is_non_overlapping_monotonic is False
+
+        # Should be False for closed='both', overwise True (GH16560)
+        idx = IntervalIndex.from_breaks(range(4), closed='both')
+        assert idx.is_non_overlapping_monotonic is False
+
+        for closed in ('left', 'right', 'neither'):
+            idx = IntervalIndex.from_breaks(range(4), closed=closed)
+            assert idx.is_non_overlapping_monotonic is True
+
 
 class TestIntervalRange(object):