Skip to content

Commit 507157d

Browse files
jschendeljreback
authored andcommitted
ENH: Add length attribute to Interval and IntervalIndex (#18805)
1 parent 175cc4f commit 507157d

File tree

6 files changed

+115
-8
lines changed

6 files changed

+115
-8
lines changed

doc/source/api.rst

+2
Original file line numberDiff line numberDiff line change
@@ -1623,6 +1623,7 @@ IntervalIndex Components
16231623
IntervalIndex.right
16241624
IntervalIndex.mid
16251625
IntervalIndex.closed
1626+
IntervalIndex.length
16261627
IntervalIndex.values
16271628
IntervalIndex.is_non_overlapping_monotonic
16281629

@@ -1995,6 +1996,7 @@ Properties
19951996
Interval.closed_left
19961997
Interval.closed_right
19971998
Interval.left
1999+
Interval.length
19982000
Interval.mid
19992001
Interval.open_left
20002002
Interval.open_right

doc/source/whatsnew/v0.23.0.txt

+1
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ Other Enhancements
141141
- ``IntervalIndex.to_tuples()`` has gained the ``na_tuple`` parameter to control whether NA is returned as a tuple of NA, or NA itself (:issue:`18756`)
142142
- ``Categorical.rename_categories``, ``CategoricalIndex.rename_categories`` and :attr:`Series.cat.rename_categories`
143143
can now take a callable as their argument (:issue:`18862`)
144+
- :class:`Interval` and :class:`IntervalIndex` have gained a ``length`` attribute (:issue:`18789`)
144145

145146
.. _whatsnew_0230.api_breaking:
146147

pandas/_libs/interval.pyx

+11-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,17 @@ cdef class IntervalMixin(object):
5454
return 0.5 * (self.left + self.right)
5555
except TypeError:
5656
# datetime safe version
57-
return self.left + 0.5 * (self.right - self.left)
57+
return self.left + 0.5 * self.length
58+
59+
@property
60+
def length(self):
61+
"""Return the length of the Interval"""
62+
try:
63+
return self.right - self.left
64+
except TypeError:
65+
# length not defined for some types, e.g. string
66+
msg = 'cannot compute length between {left!r} and {right!r}'
67+
raise TypeError(msg.format(left=self.left, right=self.right))
5868

5969

6070
cdef _interval_like(other):

pandas/core/indexes/interval.py

+27-6
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,26 @@ class IntervalIndex(IntervalMixin, Index):
116116
The indexing behaviors are provisional and may change in
117117
a future version of pandas.
118118
119-
Attributes
119+
Parameters
120120
----------
121-
left, right : array-like (1-dimensional)
122-
Left and right bounds for each interval.
121+
data : array-like (1-dimensional)
122+
Array-like containing Interval objects from which to build the
123+
IntervalIndex
123124
closed : {'left', 'right', 'both', 'neither'}, default 'right'
124125
Whether the intervals are closed on the left-side, right-side, both or
125126
neither.
126127
name : object, optional
127128
Name to be stored in the index.
128129
copy : boolean, default False
129130
Copy the meta-data
131+
132+
Attributes
133+
----------
134+
left
135+
right
136+
closed
130137
mid
138+
length
131139
values
132140
is_non_overlapping_monotonic
133141
@@ -599,6 +607,20 @@ def closed(self):
599607
"""
600608
return self._closed
601609

610+
@property
611+
def length(self):
612+
"""
613+
Return an Index with entries denoting the length of each Interval in
614+
the IntervalIndex
615+
"""
616+
try:
617+
return self.right - self.left
618+
except TypeError:
619+
# length not defined for some types, e.g. string
620+
msg = ('IntervalIndex contains Intervals without defined length, '
621+
'e.g. Intervals with string endpoints')
622+
raise TypeError(msg)
623+
602624
def __len__(self):
603625
return len(self.left)
604626

@@ -683,11 +705,10 @@ def mid(self):
683705
Return the midpoint of each Interval in the IntervalIndex as an Index
684706
"""
685707
try:
686-
return Index(0.5 * (self.left.values + self.right.values))
708+
return 0.5 * (self.left + self.right)
687709
except TypeError:
688710
# datetime safe version
689-
delta = self.right - self.left
690-
return self.left + 0.5 * delta
711+
return self.left + 0.5 * self.length
691712

692713
@cache_readonly
693714
def is_monotonic(self):

pandas/tests/indexes/test_interval.py

+30
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,36 @@ def test_properties(self, closed):
283283
tm.assert_numpy_array_equal(np.asarray(index), expected)
284284
tm.assert_numpy_array_equal(index.values, expected)
285285

286+
@pytest.mark.parametrize('breaks', [
287+
[1, 1, 2, 5, 15, 53, 217, 1014, 5335, 31240, 201608],
288+
[-np.inf, -100, -10, 0.5, 1, 1.5, 3.8, 101, 202, np.inf],
289+
pd.to_datetime(['20170101', '20170202', '20170303', '20170404']),
290+
pd.to_timedelta(['1ns', '2ms', '3s', '4M', '5H', '6D'])])
291+
def test_length(self, closed, breaks):
292+
# GH 18789
293+
index = IntervalIndex.from_breaks(breaks, closed=closed)
294+
result = index.length
295+
expected = Index(iv.length for iv in index)
296+
tm.assert_index_equal(result, expected)
297+
298+
# with NA
299+
index = index.insert(1, np.nan)
300+
result = index.length
301+
expected = Index(iv.length if notna(iv) else iv for iv in index)
302+
tm.assert_index_equal(result, expected)
303+
304+
@pytest.mark.parametrize('breaks', [
305+
list('abcdefgh'),
306+
lzip(range(10), range(1, 11)),
307+
[['A', 'B'], ['a', 'b'], ['c', 'd'], ['e', 'f']],
308+
[Interval(0, 1), Interval(1, 2), Interval(3, 4), Interval(4, 5)]])
309+
def test_length_errors(self, closed, breaks):
310+
# GH 18789
311+
index = IntervalIndex.from_breaks(breaks)
312+
msg = 'IntervalIndex contains Intervals without defined length'
313+
with tm.assert_raises_regex(TypeError, msg):
314+
index.length
315+
286316
def test_with_nans(self, closed):
287317
index = self.create_index(closed=closed)
288318
assert not index.hasnans

pandas/tests/scalar/test_interval.py

+44-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import division
22

3-
from pandas import Interval, Timestamp
3+
import numpy as np
4+
from pandas import Interval, Timestamp, Timedelta
45
from pandas.core.common import _any_none
56

67
import pytest
@@ -66,6 +67,48 @@ def test_hash(self, interval):
6667
# should not raise
6768
hash(interval)
6869

70+
@pytest.mark.parametrize('left, right, expected', [
71+
(0, 5, 5),
72+
(-2, 5.5, 7.5),
73+
(10, 10, 0),
74+
(10, np.inf, np.inf),
75+
(-np.inf, -5, np.inf),
76+
(-np.inf, np.inf, np.inf),
77+
(Timedelta('0 days'), Timedelta('5 days'), Timedelta('5 days')),
78+
(Timedelta('10 days'), Timedelta('10 days'), Timedelta('0 days')),
79+
(Timedelta('1H10M'), Timedelta('5H5M'), Timedelta('3H55M')),
80+
(Timedelta('5S'), Timedelta('1H'), Timedelta('59M55S'))])
81+
def test_length(self, left, right, expected):
82+
# GH 18789
83+
iv = Interval(left, right)
84+
result = iv.length
85+
assert result == expected
86+
87+
@pytest.mark.parametrize('left, right, expected', [
88+
('2017-01-01', '2017-01-06', '5 days'),
89+
('2017-01-01', '2017-01-01 12:00:00', '12 hours'),
90+
('2017-01-01 12:00', '2017-01-01 12:00:00', '0 days'),
91+
('2017-01-01 12:01', '2017-01-05 17:31:00', '4 days 5 hours 30 min')])
92+
@pytest.mark.parametrize('tz', (None, 'UTC', 'CET', 'US/Eastern'))
93+
def test_length_timestamp(self, tz, left, right, expected):
94+
# GH 18789
95+
iv = Interval(Timestamp(left, tz=tz), Timestamp(right, tz=tz))
96+
result = iv.length
97+
expected = Timedelta(expected)
98+
assert result == expected
99+
100+
@pytest.mark.parametrize('left, right', [
101+
('a', 'z'),
102+
(('a', 'b'), ('c', 'd')),
103+
(list('AB'), list('ab')),
104+
(Interval(0, 1), Interval(1, 2))])
105+
def test_length_errors(self, left, right):
106+
# GH 18789
107+
iv = Interval(left, right)
108+
msg = 'cannot compute length between .* and .*'
109+
with tm.assert_raises_regex(TypeError, msg):
110+
iv.length
111+
69112
def test_math_add(self, interval):
70113
expected = Interval(1, 2)
71114
actual = interval + 1

0 commit comments

Comments
 (0)