Repository: pandas-dev/pandas

Issue:
ENH: Add a length attribute to Interval and IntervalIndex
#### Problem description
Seems like a pretty standard attribute for an interval, and something that a user may want to know.

Implementation is straight forward: `self.right - self.left`, which could be an argument against adding this, since it's something a user could easily do themselves.   I think the explicit use of `length` would make code more readable.  For example, consider the datetime safe path for `Interval.mid`, the midpoint of an `Interval`:
https://github.com/pandas-dev/pandas/blob/7b4229aa1b95b2050b31cc93e38fda1a72258675/pandas/_libs/interval.pyx#L56-L57

This could be rewritten in a more explicit and clear manner with `Interval.length`:
```python
 # datetime safe version 
 return self.left + 0.5 * self.length
```

Note that I don't think this can be implemented as `__len__` for `Interval`, as I believe `__len__` is required to return an integer, and intervals do not necessarily need to have integer length.

#### Expected Output
Scalar output for an `Interval`:
```python
In [2]: iv = pd.Interval(1, 3)

In [3]: iv
Out[3]: Interval(1, 3, closed='right')

In [4]: iv.length
Out[4]: 2
```

An `Index` with entries denoting the length of each `Interval` for an `IntervalIndex`:
```python
In [5]: idx = pd.IntervalIndex.from_breaks(pd.to_datetime(['20171010', '20171020', '20171111']))

In [6]: idx
Out[6]:
IntervalIndex([(2017-10-10, 2017-10-20], (2017-10-20, 2017-11-11]]
              closed='right',
              dtype='interval[datetime64[ns]]')

In [7]: idx.length
Out[7]: TimedeltaIndex(['10 days', '22 days'], dtype='timedelta64[ns]', freq=None)

```


Discussion:
Sounds reasonable to me.  Go for it!
I meant to hit "Comment" :smile: 
lgtm.

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
@@ -116,10 +116,11 @@ class IntervalIndex(IntervalMixin, Index):
        The indexing behaviors are provisional and may change in
        a future version of pandas.
 
-    Attributes
+    Parameters
     ----------
-    left, right : array-like (1-dimensional)
-        Left and right bounds for each interval.
+    data : array-like (1-dimensional)
+        Array-like containing Interval objects from which to build the
+        IntervalIndex
     closed : {'left', 'right', 'both', 'neither'}, default 'right'
         Whether the intervals are closed on the left-side, right-side, both or
         neither.
@@ -127,7 +128,14 @@ class IntervalIndex(IntervalMixin, Index):
         Name to be stored in the index.
     copy : boolean, default False
         Copy the meta-data
+
+    Attributes
+    ----------
+    left
+    right
+    closed
     mid
+    length
     values
     is_non_overlapping_monotonic
 
@@ -599,6 +607,20 @@ def closed(self):
         """
         return self._closed
 
+    @property
+    def length(self):
+        """
+        Return an Index with entries denoting the length of each Interval in
+        the IntervalIndex
+        """
+        try:
+            return self.right - self.left
+        except TypeError:
+            # length not defined for some types, e.g. string
+            msg = ('IntervalIndex contains Intervals without defined length, '
+                   'e.g. Intervals with string endpoints')
+            raise TypeError(msg)
+
     def __len__(self):
         return len(self.left)
 
@@ -683,11 +705,10 @@ def mid(self):
         Return the midpoint of each Interval in the IntervalIndex as an Index
         """
         try:
-            return Index(0.5 * (self.left.values + self.right.values))
+            return 0.5 * (self.left + self.right)
         except TypeError:
             # datetime safe version
-            delta = self.right - self.left
-            return self.left + 0.5 * delta
+            return self.left + 0.5 * self.length
 
     @cache_readonly
     def is_monotonic(self):


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
@@ -283,6 +283,36 @@ def test_properties(self, closed):
         tm.assert_numpy_array_equal(np.asarray(index), expected)
         tm.assert_numpy_array_equal(index.values, expected)
 
+    @pytest.mark.parametrize('breaks', [
+        [1, 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'])])
+    def test_length(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)
+
+    @pytest.mark.parametrize('breaks', [
+        list('abcdefgh'),
+        lzip(range(10), range(1, 11)),
+        [['A', 'B'], ['a', 'b'], ['c', 'd'], ['e', 'f']],
+        [Interval(0, 1), Interval(1, 2), Interval(3, 4), Interval(4, 5)]])
+    def test_length_errors(self, closed, breaks):
+        # GH 18789
+        index = IntervalIndex.from_breaks(breaks)
+        msg = 'IntervalIndex contains Intervals without defined length'
+        with tm.assert_raises_regex(TypeError, msg):
+            index.length
+
     def test_with_nans(self, closed):
         index = self.create_index(closed=closed)
         assert not index.hasnans
diff --git a/pandas/tests/scalar/test_interval.py b/pandas/tests/scalar/test_interval.py
--- a/pandas/tests/scalar/test_interval.py
+++ b/pandas/tests/scalar/test_interval.py
@@ -1,6 +1,7 @@
 from __future__ import division
 
-from pandas import Interval, Timestamp
+import numpy as np
+from pandas import Interval, Timestamp, Timedelta
 from pandas.core.common import _any_none
 
 import pytest
@@ -66,6 +67,48 @@ def test_hash(self, interval):
         # should not raise
         hash(interval)
 
+    @pytest.mark.parametrize('left, right, expected', [
+        (0, 5, 5),
+        (-2, 5.5, 7.5),
+        (10, 10, 0),
+        (10, np.inf, np.inf),
+        (-np.inf, -5, np.inf),
+        (-np.inf, np.inf, np.inf),
+        (Timedelta('0 days'), Timedelta('5 days'), Timedelta('5 days')),
+        (Timedelta('10 days'), Timedelta('10 days'), Timedelta('0 days')),
+        (Timedelta('1H10M'), Timedelta('5H5M'), Timedelta('3H55M')),
+        (Timedelta('5S'), Timedelta('1H'), Timedelta('59M55S'))])
+    def test_length(self, left, right, expected):
+        # GH 18789
+        iv = Interval(left, right)
+        result = iv.length
+        assert result == expected
+
+    @pytest.mark.parametrize('left, right, expected', [
+        ('2017-01-01', '2017-01-06', '5 days'),
+        ('2017-01-01', '2017-01-01 12:00:00', '12 hours'),
+        ('2017-01-01 12:00', '2017-01-01 12:00:00', '0 days'),
+        ('2017-01-01 12:01', '2017-01-05 17:31:00', '4 days 5 hours 30 min')])
+    @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))
+        result = iv.length
+        expected = Timedelta(expected)
+        assert result == expected
+
+    @pytest.mark.parametrize('left, right', [
+        ('a', 'z'),
+        (('a', 'b'), ('c', 'd')),
+        (list('AB'), list('ab')),
+        (Interval(0, 1), Interval(1, 2))])
+    def test_length_errors(self, left, right):
+        # GH 18789
+        iv = Interval(left, right)
+        msg = 'cannot compute length between .* and .*'
+        with tm.assert_raises_regex(TypeError, msg):
+            iv.length
+
     def test_math_add(self, interval):
         expected = Interval(1, 2)
         actual = interval + 1