1"""define the IntervalIndex"""
2
3from __future__ import annotations
4
5from operator import (
6 le,
7 lt,
8)
9from typing import (
10 TYPE_CHECKING,
11 Any,
12 Literal,
13 Self,
14 cast,
15)
16
17import numpy as np
18
19from pandas._libs import lib
20from pandas._libs.interval import (
21 Interval,
22 IntervalMixin,
23 IntervalTree,
24)
25from pandas._libs.tslibs import (
26 BaseOffset,
27 Period,
28 Timedelta,
29 Timestamp,
30 to_offset,
31)
32from pandas.errors import InvalidIndexError
33from pandas.util._decorators import (
34 cache_readonly,
35 set_module,
36)
37from pandas.util._exceptions import rewrite_exception
38
39from pandas.core.dtypes.cast import (
40 find_common_type,
41 infer_dtype_from_scalar,
42 maybe_box_datetimelike,
43 maybe_downcast_numeric,
44 maybe_unbox_numpy_scalar,
45 maybe_upcast_numeric_to_64bit,
46)
47from pandas.core.dtypes.common import (
48 ensure_platform_int,
49 is_float_dtype,
50 is_integer,
51 is_integer_dtype,
52 is_list_like,
53 is_number,
54 is_object_dtype,
55 is_scalar,
56 is_string_dtype,
57 pandas_dtype,
58)
59from pandas.core.dtypes.dtypes import (
60 DatetimeTZDtype,
61 IntervalDtype,
62)
63from pandas.core.dtypes.missing import (
64 is_valid_na_for_dtype,
65 isna,
66)
67
68from pandas.core.algorithms import unique
69from pandas.core.arrays.datetimelike import validate_periods
70from pandas.core.arrays.interval import (
71 IntervalArray,
72)
73import pandas.core.common as com
74from pandas.core.indexers import is_valid_positional_slice
75from pandas.core.indexes.base import (
76 Index,
77 ensure_index,
78 maybe_extract_name,
79)
80from pandas.core.indexes.datetimes import (
81 DatetimeIndex,
82 date_range,
83)
84from pandas.core.indexes.extension import ExtensionIndex
85from pandas.core.indexes.multi import MultiIndex
86from pandas.core.indexes.timedeltas import (
87 TimedeltaIndex,
88 timedelta_range,
89)
90
91if TYPE_CHECKING:
92 from collections.abc import Hashable
93
94 from pandas._typing import (
95 Axes,
96 Dtype,
97 DtypeObj,
98 IntervalClosedType,
99 npt,
100 )
101
102
103def _get_next_label(label):
104 # see test_slice_locs_with_ints_and_floats_succeeds
105 dtype = getattr(label, "dtype", type(label))
106 if isinstance(label, (Timestamp, Timedelta)):
107 dtype = "datetime64[ns]"
108 dtype = pandas_dtype(dtype)
109
110 if lib.is_np_dtype(dtype, "mM") or isinstance(dtype, DatetimeTZDtype):
111 return label + np.timedelta64(1, "ns")
112 elif is_integer_dtype(dtype):
113 return label + 1
114 elif is_float_dtype(dtype):
115 return np.nextafter(label, np.inf)
116 else:
117 raise TypeError(f"cannot determine next label for type {type(label)!r}")
118
119
120def _get_prev_label(label):
121 # see test_slice_locs_with_ints_and_floats_succeeds
122 dtype = getattr(label, "dtype", type(label))
123 if isinstance(label, (Timestamp, Timedelta)):
124 dtype = "datetime64[ns]"
125 dtype = pandas_dtype(dtype)
126
127 if lib.is_np_dtype(dtype, "mM") or isinstance(dtype, DatetimeTZDtype):
128 return label - np.timedelta64(1, "ns")
129 elif is_integer_dtype(dtype):
130 return label - 1
131 elif is_float_dtype(dtype):
132 return np.nextafter(label, -np.inf)
133 else:
134 raise TypeError(f"cannot determine next label for type {type(label)!r}")
135
136
137def _new_IntervalIndex(cls, d):
138 """
139 This is called upon unpickling, rather than the default which doesn't have
140 arguments and breaks __new__.
141 """
142 return cls.from_arrays(**d)
143
144
145@set_module("pandas")
146class IntervalIndex(ExtensionIndex):
147 """
148 Immutable index of intervals that are closed on the same side.
149
150 An ``IntervalIndex`` stores an array of :class:`Interval` objects, all
151 sharing the same ``closed`` value, enabling vectorized operations such as
152 overlap checks, containment tests, and set-like comparisons on intervals.
153 It is commonly produced by :func:`cut`, :func:`qcut`, or
154 :func:`interval_range`.
155
156 Parameters
157 ----------
158 data : array-like (1-dimensional)
159 Array-like (ndarray, :class:`DateTimeArray`, :class:`TimeDeltaArray`) containing
160 Interval objects from which to build the IntervalIndex.
161 closed : {'left', 'right', 'both', 'neither'}, default 'right'
162 Whether the intervals are closed on the left-side, right-side, both or
163 neither.
164 dtype : dtype or None, default None
165 If None, dtype will be inferred.
166 copy : bool, default None
167 Whether to copy input data, only relevant for array, Series, and Index
168 inputs (for other input, e.g. a list, a new array is created anyway).
169 Defaults to True for array input and False for Index/Series.
170 Set to False to avoid copying array input at your own risk (if you
171 know the input data won't be modified elsewhere).
172 Set to True to force copying Series/Index input up front.
173 name : object, optional
174 Name to be stored in the index.
175 verify_integrity : bool, default True
176 Verify that the IntervalIndex is valid.
177
178 Attributes
179 ----------
180 left
181 right
182 closed
183 mid
184 length
185 is_empty
186 is_non_overlapping_monotonic
187 is_overlapping
188 values
189
190 Methods
191 -------
192 from_arrays
193 from_tuples
194 from_breaks
195 get_loc
196 get_indexer
197 contains
198 overlaps
199 set_closed
200 to_tuples
201
202 See Also
203 --------
204 Index : The base pandas Index type.
205 Interval : A bounded slice-like interval; the elements of an IntervalIndex.
206 interval_range : Function to create a fixed frequency IntervalIndex.
207 cut : Bin values into discrete Intervals.
208 qcut : Bin values into equal-sized Intervals based on rank or sample quantiles.
209
210 Notes
211 -----
212 See the `user guide
213 <https://pandas.pydata.org/pandas-docs/stable/user_guide/advanced.html#intervalindex>`__
214 for more.
215
216 Examples
217 --------
218 A new ``IntervalIndex`` is typically constructed using
219 :func:`interval_range`:
220
221 >>> pd.interval_range(start=0, end=5)
222 IntervalIndex([(0, 1], (1, 2], (2, 3], (3, 4], (4, 5]],
223 dtype='interval[int64, right]')
224
225 It may also be constructed using one of the constructor
226 methods: :meth:`IntervalIndex.from_arrays`,
227 :meth:`IntervalIndex.from_breaks`, and :meth:`IntervalIndex.from_tuples`.
228
229 See further examples in the doc strings of ``interval_range`` and the
230 mentioned constructor methods.
231 """
232
233 _typ = "intervalindex"
234
235 _data: IntervalArray
236 _values: IntervalArray
237 _can_hold_strings = False
238 _data_cls = IntervalArray
239
240 # --------------------------------------------------------------------
241 # Descriptors and methods dispatched to the underlying IntervalArray
242
243 @cache_readonly
244 def closed(self) -> IntervalClosedType:
245 """
246 String describing the inclusive side of the intervals.
247
248 Either ``left``, ``right``, ``both`` or ``neither``.
249
250 See Also
251 --------
252 IntervalIndex.set_closed : Return an IntervalIndex identical to the
253 current one, but closed on the specified side.
254 Interval.closed : Returns inclusive side of the Interval.
255
256 Examples
257 --------
258 >>> interv_idx = pd.interval_range(start=0, end=2)
259 >>> interv_idx
260 IntervalIndex([(0, 1], (1, 2]], dtype='interval[int64, right]')
261 >>> interv_idx.closed
262 'right'
263 """
264 return self._data.closed
265
266 @cache_readonly
267 def is_non_overlapping_monotonic(self) -> bool:
268 """
269 Return True if the IntervalIndex is non-overlapping and monotonic.
270
271 Non-overlapping means no Intervals share points, and monotonic means
272 either monotonic increasing or monotonic decreasing.
273
274 See Also
275 --------
276 IntervalIndex.overlaps : Check an IntervalIndex elementwise for
277 overlaps.
278 IntervalIndex.is_overlapping : Return True if the IntervalIndex has
279 overlapping intervals.
280
281 Examples
282 --------
283 >>> interv_idx = pd.interval_range(start=0, end=2)
284 >>> interv_idx
285 IntervalIndex([(0, 1], (1, 2]], dtype='interval[int64, right]')
286 >>> interv_idx.is_non_overlapping_monotonic
287 True
288
289 >>> interv_idx = pd.interval_range(start=0, end=2, closed="both")
290 >>> interv_idx
291 IntervalIndex([[0, 1], [1, 2]], dtype='interval[int64, both]')
292 >>> interv_idx.is_non_overlapping_monotonic
293 False
294 """
295 return self._data.is_non_overlapping_monotonic
296
297 @property
298 def closed_left(self) -> bool:
299 """
300 Check if the intervals are closed on the left side.
301
302 All intervals in an IntervalIndex share the same ``closed`` value,
303 so this describes the index as a whole rather than element-wise.
304
305 See Also
306 --------
307 IntervalIndex.closed_right : Check if the intervals are closed on
308 the right side.
309 IntervalIndex.open_left : Boolean inverse of closed_left.
310
311 Examples
312 --------
313 >>> iv_idx = pd.interval_range(start=0, end=2, closed="left")
314 >>> iv_idx.closed_left
315 True
316
317 >>> iv_idx = pd.interval_range(start=0, end=2, closed="right")
318 >>> iv_idx.closed_left
319 False
320 """
321 return self._data.closed_left
322
323 @property
324 def closed_right(self) -> bool:
325 """
326 Check if the intervals are closed on the right side.
327
328 All intervals in an IntervalIndex share the same ``closed`` value,
329 so this describes the index as a whole rather than element-wise.
330
331 See Also
332 --------
333 IntervalIndex.closed_left : Check if the intervals are closed on
334 the left side.
335 IntervalIndex.open_right : Boolean inverse of closed_right.
336
337 Examples
338 --------
339 >>> iv_idx = pd.interval_range(start=0, end=2, closed="right")
340 >>> iv_idx.closed_right
341 True
342
343 >>> iv_idx = pd.interval_range(start=0, end=2, closed="left")
344 >>> iv_idx.closed_right
345 False
346 """
347 return self._data.closed_right
348
349 @property
350 def open_left(self) -> bool:
351 """
352 Check if the intervals are open on the left side.
353
354 All intervals in an IntervalIndex share the same ``closed`` value,
355 so this describes the index as a whole rather than element-wise.
356
357 See Also
358 --------
359 IntervalIndex.open_right : Check if the intervals are open on the
360 right side.
361 IntervalIndex.closed_left : Boolean inverse of open_left.
362
363 Examples
364 --------
365 >>> iv_idx = pd.interval_range(start=0, end=2, closed="left")
366 >>> iv_idx.open_left
367 False
368
369 >>> iv_idx = pd.interval_range(start=0, end=2, closed="right")
370 >>> iv_idx.open_left
371 True
372 """
373 return self._data.open_left
374
375 @property
376 def open_right(self) -> bool:
377 """
378 Check if the intervals are open on the right side.
379
380 All intervals in an IntervalIndex share the same ``closed`` value,
381 so this describes the index as a whole rather than element-wise.
382
383 See Also
384 --------
385 IntervalIndex.open_left : Check if the intervals are open on the
386 left side.
387 IntervalIndex.closed_right : Boolean inverse of open_right.
388
389 Examples
390 --------
391 >>> iv_idx = pd.interval_range(start=0, end=2, closed="right")
392 >>> iv_idx.open_right
393 False
394
395 >>> iv_idx = pd.interval_range(start=0, end=2, closed="left")
396 >>> iv_idx.open_right
397 True
398 """
399 return self._data.open_right
400
401 @property
402 def is_empty(self) -> npt.NDArray[np.bool_]:
403 """
404 Indicates if an interval is empty, meaning it contains no points.
405
406 An interval is considered empty if its left and right endpoints are
407 equal, and it is not closed on both sides. Returns a boolean ndarray
408 positionally indicating the emptiness of each interval.
409
410 Returns
411 -------
412 numpy.ndarray[bool]
413 A boolean ndarray positionally indicating if an Interval is empty.
414
415 See Also
416 --------
417 Interval.is_empty : Indicates if a scalar Interval is empty.
418 IntervalIndex.length : Return the length of each interval.
419
420 Examples
421 --------
422 >>> ivs = [
423 ... pd.Interval(0, 0, closed="neither"),
424 ... pd.Interval(1, 2, closed="neither"),
425 ... ]
426 >>> pd.IntervalIndex(ivs).is_empty
427 array([ True, False])
428
429 Missing values are not considered empty:
430
431 >>> ivs = [pd.Interval(0, 0, closed="neither"), np.nan]
432 >>> pd.IntervalIndex(ivs).is_empty
433 array([ True, False])
434 """
435 # error: Incompatible return value type (got "bool", expected
436 # "ndarray[Any, dtype[bool_]]") -- the IntervalMixin stub only types
437 # the Interval scalar case, not the IntervalArray/IntervalIndex case
438 return self._data.is_empty # type: ignore[return-value]
439
440 def set_closed(self, closed: IntervalClosedType) -> Self:
441 """
442 Return an identical IntervalIndex closed on the specified side.
443
444 This method creates a new IntervalIndex with the same bounds but with
445 a different closure specification.
446
447 Parameters
448 ----------
449 closed : {'left', 'right', 'both', 'neither'}
450 Whether the intervals are closed on the left-side, right-side, both
451 or neither.
452
453 Returns
454 -------
455 IntervalIndex
456 A new IntervalIndex with the specified side closures.
457
458 See Also
459 --------
460 IntervalIndex.closed : Returns inclusive side of the IntervalIndex.
461
462 Examples
463 --------
464 >>> index = pd.interval_range(0, 3)
465 >>> index
466 IntervalIndex([(0, 1], (1, 2], (2, 3]],
467 dtype='interval[int64, right]')
468 >>> index.set_closed("both")
469 IntervalIndex([[0, 1], [1, 2], [2, 3]],
470 dtype='interval[int64, both]')
471 """
472 array = self._data.set_closed(closed)
473 return type(self)._simple_new(array, name=self.name)
474
475 def to_tuples(self, na_tuple: bool = True) -> Index:
476 """
477 Return an Index of tuples of the form (left, right).
478
479 This method extracts the bounds of each interval as a tuple,
480 useful for iteration or conversion to other data structures.
481
482 Parameters
483 ----------
484 na_tuple : bool, default True
485 If ``True``, return ``NA`` as a tuple ``(nan, nan)``. If ``False``,
486 just return ``NA`` as ``nan``.
487
488 Returns
489 -------
490 Index
491 An Index of tuples representing the intervals.
492
493 See Also
494 --------
495 arrays.IntervalArray.to_tuples : Analogous method for IntervalArray.
496
497 Examples
498 --------
499 >>> idx = pd.interval_range(start=0, end=2)
500 >>> idx
501 IntervalIndex([(0, 1], (1, 2]], dtype='interval[int64, right]')
502 >>> idx.to_tuples()
503 Index([(0, 1), (1, 2)], dtype='object')
504 """
505 result = self._data.to_tuples(na_tuple=na_tuple)
506 return Index(result, name=self.name, dtype=result.dtype, copy=False)
507
508 def overlaps(self, other: Interval) -> npt.NDArray[np.bool_]:
509 """
510 Check elementwise if an Interval overlaps the values in the IntervalIndex.
511
512 Two intervals overlap if they share a common point, including closed
513 endpoints. Intervals that only have an open endpoint in common do not
514 overlap, and empty intervals contain no points at all, so they never
515 overlap.
516
517 Parameters
518 ----------
519 other : Interval
520 Interval to check against for an overlap.
521
522 Returns
523 -------
524 ndarray
525 Boolean array positionally indicating where an overlap occurs.
526
527 See Also
528 --------
529 Interval.overlaps : Check whether two Interval objects overlap.
530
531 Examples
532 --------
533 >>> data = [(0, 1), (1, 3), (2, 4)]
534 >>> intervals = pd.IntervalIndex.from_tuples(data)
535 >>> intervals
536 IntervalIndex([(0, 1], (1, 3], (2, 4]], dtype='interval[int64, right]')
537
538 >>> intervals.overlaps(pd.Interval(0.5, 1.5))
539 array([ True, True, False])
540
541 Intervals that share closed endpoints overlap:
542
543 >>> intervals.overlaps(pd.Interval(1, 3, closed="left"))
544 array([ True, True, True])
545
546 Intervals that only have an open endpoint in common do not overlap:
547
548 >>> intervals.overlaps(pd.Interval(1, 2, closed="right"))
549 array([False, True, False])
550 """
551 return self._data.overlaps(other)
552
553 def contains(self, other) -> npt.NDArray[np.bool_]:
554 """
555 Check elementwise if the Intervals contain the value.
556
557 Return a boolean mask whether the value is contained in the Intervals
558 of the IntervalIndex.
559
560 Parameters
561 ----------
562 other : scalar
563 The value to check whether it is contained in the Intervals.
564
565 Returns
566 -------
567 numpy.ndarray[bool]
568 A boolean mask whether the value is contained in the Intervals.
569
570 See Also
571 --------
572 IntervalIndex.__contains__ : Check whether an Interval is one of the
573 IntervalIndex's elements.
574 IntervalIndex.overlaps : Check if an Interval overlaps the values in
575 the IntervalIndex.
576
577 Examples
578 --------
579 >>> intervals = pd.IntervalIndex.from_tuples([(0, 1), (1, 3), (2, 4)])
580 >>> intervals
581 IntervalIndex([(0, 1], (1, 3], (2, 4]], dtype='interval[int64, right]')
582
583 >>> intervals.contains(0.5)
584 array([ True, False, False])
585 """
586 return self._data.contains(other)
587
588 # --------------------------------------------------------------------
589 # Constructors
590
591 def __new__(
592 cls,
593 data,
594 closed: IntervalClosedType | None = None,
595 dtype: Dtype | None = None,
596 copy: bool | None = None,
597 name: Hashable | None = None,
598 verify_integrity: bool = True,
599 ) -> Self:
600 name = maybe_extract_name(name, data, cls)
601
602 # GH#63388
603 data, copy = cls._maybe_copy_array_input(data, copy, dtype)
604
605 with rewrite_exception("IntervalArray", cls.__name__):
606 array = IntervalArray(
607 data,
608 closed=closed,
609 copy=copy,
610 dtype=dtype,
611 verify_integrity=verify_integrity,
612 )
613
614 return cls._simple_new(array, name)
615
616 @classmethod
617 def from_breaks(
618 cls,
619 breaks,
620 closed: IntervalClosedType | None = "right",
621 name: Hashable | None = None,
622 copy: bool = False,
623 dtype: Dtype | None = None,
624 ) -> IntervalIndex:
625 """
626 Construct an IntervalIndex from an array of splits.
627
628 The *breaks* array of length *N* is converted into *N-1* adjacent,
629 non-overlapping intervals whose endpoints are consecutive pairs of
630 break values.
631
632 Parameters
633 ----------
634 breaks : array-like (1-dimensional)
635 Left and right bounds for each interval.
636 closed : {'left', 'right', 'both', 'neither'}, default 'right'
637 Whether the intervals are closed on the left-side, right-side, both
638 or neither.
639 name : str, optional
640 Name of the resulting IntervalIndex.
641 copy : bool, default False
642 Copy the data.
643 dtype : dtype or None, default None
644 If None, dtype will be inferred.
645
646 Returns
647 -------
648 IntervalIndex
649
650 See Also
651 --------
652 interval_range : Function to create a fixed frequency IntervalIndex.
653 IntervalIndex.from_arrays : Construct from a left and right array.
654 IntervalIndex.from_tuples : Construct from a sequence of tuples.
655
656 Examples
657 --------
658 >>> pd.IntervalIndex.from_breaks([0, 1, 2, 3])
659 IntervalIndex([(0, 1], (1, 2], (2, 3]],
660 dtype='interval[int64, right]')
661 """
662 with rewrite_exception("IntervalArray", cls.__name__):
663 array = IntervalArray.from_breaks(
664 breaks, closed=closed, copy=copy, dtype=dtype
665 )
666 return cls._simple_new(array, name=name)
667
668 @classmethod
669 def from_arrays(
670 cls,
671 left,
672 right,
673 closed: IntervalClosedType = "right",
674 name: Hashable | None = None,
675 copy: bool = False,
676 dtype: Dtype | None = None,
677 ) -> IntervalIndex:
678 """
679 Construct from two arrays defining the left and right bounds.
680
681 Each interval is formed by pairing the corresponding elements of
682 *left* and *right*, producing an :class:`IntervalIndex` of the same
683 length as the input arrays.
684
685 Parameters
686 ----------
687 left : array-like (1-dimensional)
688 Left bounds for each interval.
689 right : array-like (1-dimensional)
690 Right bounds for each interval.
691 closed : {'left', 'right', 'both', 'neither'}, default 'right'
692 Whether the intervals are closed on the left-side, right-side, both
693 or neither.
694 name : str, optional
695 Name of the resulting IntervalIndex.
696 copy : bool, default False
697 Copy the data.
698 dtype : dtype, optional
699 If None, dtype will be inferred.
700
701 Returns
702 -------
703 IntervalIndex
704
705 Raises
706 ------
707 ValueError
708 When a value is missing in only one of `left` or `right`.
709 When a value in `left` is greater than the corresponding value
710 in `right`.
711
712 See Also
713 --------
714 interval_range : Function to create a fixed frequency IntervalIndex.
715 IntervalIndex.from_breaks : Construct an IntervalIndex from an array of
716 splits.
717 IntervalIndex.from_tuples : Construct an IntervalIndex from an
718 array-like of tuples.
719
720 Notes
721 -----
722 Each element of `left` must be less than or equal to the `right`
723 element at the same position. If an element is missing, it must be
724 missing in both `left` and `right`. A TypeError is raised when
725 using an unsupported type for `left` or `right`. At the moment,
726 'category', 'object', and 'string' subtypes are not supported.
727
728 Examples
729 --------
730 >>> pd.IntervalIndex.from_arrays([0, 1, 2], [1, 2, 3])
731 IntervalIndex([(0, 1], (1, 2], (2, 3]],
732 dtype='interval[int64, right]')
733 """
734 with rewrite_exception("IntervalArray", cls.__name__):
735 array = IntervalArray.from_arrays(
736 left, right, closed, copy=copy, dtype=dtype
737 )
738 return cls._simple_new(array, name=name)
739
740 @classmethod
741 def from_tuples(
742 cls,
743 data,
744 closed: IntervalClosedType = "right",
745 name: Hashable | None = None,
746 copy: bool = False,
747 dtype: Dtype | None = None,
748 ) -> IntervalIndex:
749 """
750 Construct an IntervalIndex from an array-like of tuples.
751
752 Each tuple in *data* should contain exactly two elements representing
753 the left and right bounds of an interval.
754
755 Parameters
756 ----------
757 data : array-like (1-dimensional)
758 Array of tuples.
759 closed : {'left', 'right', 'both', 'neither'}, default 'right'
760 Whether the intervals are closed on the left-side, right-side, both
761 or neither.
762 name : str, optional
763 Name of the resulting IntervalIndex.
764 copy : bool, default False
765 By-default copy the data, this is compat only and ignored.
766 dtype : dtype or None, default None
767 If None, dtype will be inferred.
768
769 Returns
770 -------
771 IntervalIndex
772
773 See Also
774 --------
775 interval_range : Function to create a fixed frequency IntervalIndex.
776 IntervalIndex.from_arrays : Construct an IntervalIndex from a left and
777 right array.
778 IntervalIndex.from_breaks : Construct an IntervalIndex from an array of
779 splits.
780
781 Examples
782 --------
783 >>> pd.IntervalIndex.from_tuples([(0, 1), (1, 2)])
784 IntervalIndex([(0, 1], (1, 2]],
785 dtype='interval[int64, right]')
786 """
787 with rewrite_exception("IntervalArray", cls.__name__):
788 arr = IntervalArray.from_tuples(data, closed=closed, copy=copy, dtype=dtype)
789 return cls._simple_new(arr, name=name)
790
791 # --------------------------------------------------------------------
792 # error: Return type "IntervalTree" of "_engine" incompatible with return type
793 # "Union[IndexEngine, ExtensionEngine]" in supertype "Index"
794 @cache_readonly
795 def _engine(self) -> IntervalTree: # type: ignore[override]
796 # IntervalTree does not supports numpy array unless they are 64 bit
797 left = self._maybe_convert_i8(self.left)
798 left = maybe_upcast_numeric_to_64bit(left)
799 right = self._maybe_convert_i8(self.right)
800 right = maybe_upcast_numeric_to_64bit(right)
801 return IntervalTree(left, right, closed=self.closed)
802
803 def __contains__(self, key: Any) -> bool:
804 """
805 return a boolean if this key is IN the index
806 We *only* accept an Interval
807
808 Parameters
809 ----------
810 key : Interval
811
812 Returns
813 -------
814 bool
815 """
816 hash(key)
817 if not isinstance(key, Interval):
818 if is_valid_na_for_dtype(key, self.dtype):
819 return self.hasnans
820 return False
821
822 try:
823 self.get_loc(key)
824 return True
825 except KeyError:
826 return False
827
828 def _getitem_slice(self, slobj: slice) -> IntervalIndex:
829 """
830 Fastpath for __getitem__ when we know we have a slice.
831 """
832 res = self._data[slobj]
833 return type(self)._simple_new(res, name=self._name)
834
835 @cache_readonly
836 def _multiindex(self) -> MultiIndex:
837 return MultiIndex.from_arrays([self.left, self.right], names=["left", "right"])
838
839 def __reduce__(self):
840 d = {
841 "left": self.left,
842 "right": self.right,
843 "closed": self.closed,
844 "name": self.name,
845 }
846 return _new_IntervalIndex, (type(self), d), None
847
848 @property
849 def inferred_type(self) -> str:
850 """Return a string of the type inferred from the values"""
851 return "interval"
852
853 def memory_usage(self, deep: bool = False) -> int:
854 """
855 Memory usage of the values.
856
857 Parameters
858 ----------
859 deep : bool, default False
860 Introspect the data deeply, interrogate
861 `object` dtypes for system-level memory consumption.
862
863 Returns
864 -------
865 bytes used
866 Returns memory usage of the values in the Index in bytes.
867
868 See Also
869 --------
870 numpy.ndarray.nbytes : Total bytes consumed by the elements of the
871 array.
872
873 Notes
874 -----
875 Memory usage does not include memory consumed by elements that
876 are not components of the array if deep=False or if used on PyPy
877
878 Examples
879 --------
880 >>> idx = pd.Index([1, 2, 3])
881 >>> idx.memory_usage()
882 24
883 """
884 # we don't use an explicit engine
885 # so return the bytes here
886 return self.left.memory_usage(deep=deep) + self.right.memory_usage(deep=deep)
887
888 # IntervalTree doesn't have a is_monotonic_decreasing, so have to override
889 # the Index implementation
890 @cache_readonly
891 def is_monotonic_decreasing(self) -> bool:
892 """
893 Return True if the IntervalIndex is monotonic decreasing (only equal or
894 decreasing values), else False
895 """
896 return self[::-1].is_monotonic_increasing
897
898 @cache_readonly
899 def is_unique(self) -> bool:
900 """
901 Return True if the IntervalIndex contains unique elements, else False.
902 """
903 left = self.left
904 right = self.right
905
906 if self.isna().sum() > 1:
907 return False
908
909 if left.is_unique or right.is_unique:
910 return True
911
912 seen_pairs = set()
913 check_idx = np.where(left.duplicated(keep=False))[0]
914 for idx in check_idx:
915 pair = (left[idx], right[idx])
916 if pair in seen_pairs:
917 return False
918 seen_pairs.add(pair)
919
920 return True
921
922 @property
923 def is_overlapping(self) -> bool:
924 """
925 Return True if the IntervalIndex has overlapping intervals, else False.
926
927 Two intervals overlap if they share a common point, including closed
928 endpoints. Intervals that only have an open endpoint in common do not
929 overlap, and empty intervals contain no points at all, so they never
930 overlap.
931
932 Returns
933 -------
934 bool
935 Boolean indicating if the IntervalIndex has overlapping intervals.
936
937 See Also
938 --------
939 Interval.overlaps : Check whether two Interval objects overlap.
940 IntervalIndex.overlaps : Check an IntervalIndex elementwise for
941 overlaps.
942
943 Examples
944 --------
945 >>> index = pd.IntervalIndex.from_tuples([(0, 2), (1, 3), (4, 5)])
946 >>> index
947 IntervalIndex([(0, 2], (1, 3], (4, 5]],
948 dtype='interval[int64, right]')
949 >>> index.is_overlapping
950 True
951
952 Intervals that share closed endpoints overlap:
953
954 >>> index = pd.interval_range(0, 3, closed="both")
955 >>> index
956 IntervalIndex([[0, 1], [1, 2], [2, 3]],
957 dtype='interval[int64, both]')
958 >>> index.is_overlapping
959 True
960
961 Intervals that only have an open endpoint in common do not overlap:
962
963 >>> index = pd.interval_range(0, 3, closed="left")
964 >>> index
965 IntervalIndex([[0, 1), [1, 2), [2, 3)],
966 dtype='interval[int64, left]')
967 >>> index.is_overlapping
968 False
969
970 Empty intervals never overlap, even when nested inside another interval:
971
972 >>> index = pd.IntervalIndex.from_tuples([(0, 3), (1, 1)], closed="left")
973 >>> index
974 IntervalIndex([[0, 3), [1, 1)],
975 dtype='interval[int64, left]')
976 >>> index.is_overlapping
977 False
978 """
979 # GH 23309
980 return self._engine.is_overlapping
981
982 def _needs_i8_conversion(self, key) -> bool:
983 """
984 Check if a given key needs i8 conversion. Conversion is necessary for
985 Timestamp, Timedelta, DatetimeIndex, and TimedeltaIndex keys. An
986 Interval-like requires conversion if its endpoints are one of the
987 aforementioned types.
988
989 Assumes that any list-like data has already been cast to an Index.
990
991 Parameters
992 ----------
993 key : scalar or Index-like
994 The key that should be checked for i8 conversion
995
996 Returns
997 -------
998 bool
999 """
1000 key_dtype = getattr(key, "dtype", None)
1001 if isinstance(key_dtype, IntervalDtype) or isinstance(key, Interval):
1002 return self._needs_i8_conversion(key.left)
1003
1004 i8_types = (Timestamp, Timedelta, DatetimeIndex, TimedeltaIndex)
1005 return isinstance(key, i8_types)
1006
1007 def _maybe_convert_i8(self, key):
1008 """
1009 Maybe convert a given key to its equivalent i8 value(s). Used as a
1010 preprocessing step prior to IntervalTree queries (self._engine), which
1011 expects numeric data.
1012
1013 Parameters
1014 ----------
1015 key : scalar or list-like
1016 The key that should maybe be converted to i8.
1017
1018 Returns
1019 -------
1020 scalar or list-like
1021 The original key if no conversion occurred, int if converted scalar,
1022 Index with an int64 dtype if converted list-like.
1023 """
1024 if is_list_like(key):
1025 key = ensure_index(key)
1026 key = maybe_upcast_numeric_to_64bit(key)
1027
1028 if not self._needs_i8_conversion(key):
1029 return key
1030
1031 scalar = is_scalar(key)
1032 key_dtype = getattr(key, "dtype", None)
1033 if isinstance(key_dtype, IntervalDtype) or isinstance(key, Interval):
1034 # convert left/right and reconstruct
1035 left = self._maybe_convert_i8(key.left)
1036 right = self._maybe_convert_i8(key.right)
1037 constructor = Interval if scalar else IntervalIndex.from_arrays
1038 return constructor(left, right, closed=self.closed)
1039
1040 if scalar:
1041 # Timestamp/Timedelta
1042 key_dtype, key_i8 = infer_dtype_from_scalar(key)
1043 if isinstance(key, Period):
1044 key_i8 = key.ordinal
1045 elif isinstance(key_i8, Timestamp):
1046 key_i8 = key_i8._value
1047 elif isinstance(key_i8, (np.datetime64, np.timedelta64)):
1048 key_i8 = key_i8.view("i8")
1049 else:
1050 # DatetimeIndex/TimedeltaIndex
1051 key_dtype, key_i8 = key.dtype, Index(key.asi8, copy=False)
1052 if key.hasnans:
1053 # convert NaT from its i8 value to np.nan so it's not viewed
1054 # as a valid value, maybe causing errors (e.g. is_overlapping)
1055 key_i8 = key_i8.where(~key._isnan)
1056
1057 # ensure consistency with IntervalIndex subtype
1058 # error: Item "ExtensionDtype"/"dtype[Any]" of "Union[dtype[Any],
1059 # ExtensionDtype]" has no attribute "subtype"
1060 subtype = self.dtype.subtype # type: ignore[union-attr]
1061
1062 if subtype != key_dtype:
1063 raise ValueError(
1064 f"Cannot index an IntervalIndex of subtype {subtype} with "
1065 f"values of dtype {key_dtype}"
1066 )
1067
1068 return key_i8
1069
1070 def _searchsorted_monotonic(self, label, side: Literal["left", "right"] = "left"):
1071 if not self.is_non_overlapping_monotonic:
1072 raise KeyError(
1073 "can only get slices from an IntervalIndex if bounds are "
1074 "non-overlapping and all monotonic increasing or decreasing"
1075 )
1076
1077 if isinstance(label, (IntervalMixin, IntervalIndex)):
1078 raise NotImplementedError("Interval objects are not currently supported")
1079
1080 # GH 20921: "not is_monotonic_increasing" for the second condition
1081 # instead of "is_monotonic_decreasing" to account for single element
1082 # indexes being both increasing and decreasing
1083 if (side == "left" and self.left.is_monotonic_increasing) or (
1084 side == "right" and not self.left.is_monotonic_increasing
1085 ):
1086 sub_idx = self.right
1087 if self.open_right:
1088 label = _get_next_label(label)
1089 else:
1090 sub_idx = self.left
1091 if self.open_left:
1092 label = _get_prev_label(label)
1093
1094 return sub_idx._searchsorted_monotonic(label, side)
1095
1096 # --------------------------------------------------------------------
1097 # Indexing Methods
1098
1099 def get_loc(self, key) -> int | slice | np.ndarray:
1100 """
1101 Get integer location, slice or boolean mask for requested label.
1102
1103 The `get_loc` method is used to retrieve the integer index, a slice for
1104 slicing objects, or a boolean mask indicating the presence of the label
1105 in the `IntervalIndex`.
1106
1107 Parameters
1108 ----------
1109 key : label
1110 The value or range to find in the IntervalIndex.
1111
1112 Returns
1113 -------
1114 int if unique index, slice if monotonic index, else mask
1115 The position or positions found. This could be a single
1116 number, a range, or an array of true/false values
1117 indicating the position(s) of the label.
1118
1119 See Also
1120 --------
1121 IntervalIndex.get_indexer_non_unique : Compute indexer and
1122 mask for new index given the current index.
1123 Index.get_loc : Similar method in the base Index class.
1124
1125 Examples
1126 --------
1127 >>> i1, i2 = pd.Interval(0, 1), pd.Interval(1, 2)
1128 >>> index = pd.IntervalIndex([i1, i2])
1129 >>> index.get_loc(1)
1130 0
1131
1132 You can also supply a point inside an interval.
1133
1134 >>> index.get_loc(1.5)
1135 1
1136
1137 If a label is in several intervals, you get the locations of all the
1138 relevant intervals.
1139
1140 >>> i3 = pd.Interval(0, 2)
1141 >>> overlapping_index = pd.IntervalIndex([i1, i2, i3])
1142 >>> overlapping_index.get_loc(0.5)
1143 array([ True, False, True])
1144
1145 Only exact matches will be returned if an interval is provided.
1146
1147 >>> index.get_loc(pd.Interval(0, 1))
1148 0
1149 """
1150 self._check_indexing_error(key)
1151
1152 if isinstance(key, Interval):
1153 if self.closed != key.closed:
1154 raise KeyError(key)
1155 mask = (self.left == key.left) & (self.right == key.right)
1156 elif is_valid_na_for_dtype(key, self.dtype):
1157 mask = self.isna()
1158 else:
1159 # assume scalar
1160 op_left = le if self.closed_left else lt
1161 op_right = le if self.closed_right else lt
1162 try:
1163 mask = op_left(self.left, key) & op_right(key, self.right)
1164 except TypeError as err:
1165 # scalar is not comparable to II subtype --> invalid label
1166 raise KeyError(key) from err
1167
1168 matches = mask.sum()
1169 if matches == 0:
1170 raise KeyError(key)
1171 if matches == 1:
1172 return maybe_unbox_numpy_scalar(mask.argmax())
1173
1174 res = lib.maybe_booleans_to_slice(mask.view("u1"))
1175 if isinstance(res, slice) and res.stop is None:
1176 # TODO: DO this in maybe_booleans_to_slice?
1177 res = slice(res.start, len(self), res.step)
1178 return res
1179
1180 def _get_indexer(
1181 self,
1182 target: Index,
1183 method: str | None = None,
1184 limit: int | None = None,
1185 tolerance: Any | None = None,
1186 ) -> npt.NDArray[np.intp]:
1187 if isinstance(target, IntervalIndex):
1188 # We only get here with not self.is_overlapping
1189 # -> at most one match per interval in target
1190 # want exact matches -> need both left/right to match, so defer to
1191 # left/right get_indexer, compare elementwise, equality -> match
1192 if self.left.is_unique and self.right.is_unique:
1193 indexer = self._get_indexer_unique_sides(target)
1194 else:
1195 indexer = self._get_indexer_pointwise(target)[0]
1196
1197 elif not (is_object_dtype(target.dtype) or is_string_dtype(target.dtype)):
1198 # homogeneous scalar index
1199 # we should always have self._should_partial_index(target) here
1200 if self.is_monotonic_increasing and self.right.is_monotonic_increasing:
1201 # GH#47614 - use searchsorted for O(n*log(m)) instead of
1202 # IntervalTree which scales poorly for large target arrays.
1203 # right is checked too: an empty interval can nest inside
1204 # another without overlapping it (GH#26893)
1205 indexer = self._get_indexer_monotonic(target)
1206 else:
1207 target = self._maybe_convert_i8(target)
1208 indexer = self._engine.get_indexer(cast("np.ndarray", target.values))
1209 else:
1210 # heterogeneous scalar index: defer elementwise to get_loc
1211 # we should always have self._should_partial_index(target) here
1212 return self._get_indexer_pointwise(target)[0]
1213
1214 return ensure_platform_int(indexer)
1215
1216 def get_indexer_non_unique(
1217 self, target: Axes
1218 ) -> tuple[npt.NDArray[np.intp], npt.NDArray[np.intp]]:
1219 """
1220 Compute indexer and mask for new index given the current index.
1221
1222 The indexer should be then used as an input to ndarray.take to align the
1223 current data to the new index.
1224
1225 Parameters
1226 ----------
1227 target : IntervalIndex or list of Intervals
1228 An iterable containing the values to be used for computing indexer.
1229
1230 Returns
1231 -------
1232 indexer : np.ndarray[np.intp]
1233 Integers from 0 to n - 1 indicating that the index at these
1234 positions matches the corresponding target values. Missing values
1235 in the target are marked by -1.
1236 missing : np.ndarray[np.intp]
1237 An indexer into the target of the values not found.
1238 These correspond to the -1 in the indexer array.
1239
1240 See Also
1241 --------
1242 Index.get_indexer : Computes indexer and mask for new index given
1243 the current index.
1244 Index.get_indexer_for : Returns an indexer even when non-unique.
1245
1246 Examples
1247 --------
1248 >>> index = pd.Index(["c", "b", "a", "b", "b"])
1249 >>> index.get_indexer_non_unique(["b", "b"])
1250 (array([1, 3, 4, 1, 3, 4]), array([], dtype=int64))
1251
1252 In the example below there are no matched values.
1253
1254 >>> index = pd.Index(["c", "b", "a", "b", "b"])
1255 >>> index.get_indexer_non_unique(["q", "r", "t"])
1256 (array([-1, -1, -1]), array([0, 1, 2]))
1257
1258 For this reason, the returned ``indexer`` contains only integers equal to -1.
1259 It demonstrates that there's no match between the index and the ``target``
1260 values at these positions. The mask [0, 1, 2] in the return value shows that
1261 the first, second, and third elements are missing.
1262
1263 Notice that the return value is a tuple contains two items. In the example
1264 below the first item is an array of locations in ``index``. The second
1265 item is a mask shows that the first and third elements are missing.
1266
1267 >>> index = pd.Index(["c", "b", "a", "b", "b"])
1268 >>> index.get_indexer_non_unique(["f", "b", "s"])
1269 (array([-1, 1, 3, 4, -1]), array([0, 2]))
1270 """
1271 target = ensure_index(target)
1272
1273 if not self._should_compare(target) and not self._should_partial_index(target):
1274 # e.g. IntervalIndex with different closed or incompatible subtype
1275 # -> no matches
1276 return self._get_indexer_non_comparable(target, None, unique=False)
1277
1278 elif isinstance(target, IntervalIndex):
1279 if self.left.is_unique and self.right.is_unique:
1280 # fastpath available even if we don't have self._index_as_unique
1281 indexer = self._get_indexer_unique_sides(target)
1282 missing = (indexer == -1).nonzero()[0]
1283 else:
1284 return self._get_indexer_pointwise(target)
1285
1286 elif is_object_dtype(target.dtype) or not self._should_partial_index(target):
1287 # target might contain intervals: defer elementwise to get_loc
1288 return self._get_indexer_pointwise(target)
1289
1290 else:
1291 # Note: this case behaves differently from other Index subclasses
1292 # because IntervalIndex does partial-int indexing
1293 target = self._maybe_convert_i8(target)
1294 indexer, missing = self._engine.get_indexer_non_unique(
1295 cast("np.ndarray", target.values) # type: ignore[union-attr]
1296 )
1297
1298 return ensure_platform_int(indexer), ensure_platform_int(missing)
1299
1300 def _get_indexer_unique_sides(self, target: IntervalIndex) -> npt.NDArray[np.intp]:
1301 """
1302 _get_indexer specialized to the case where both of our sides are unique.
1303 """
1304 # Caller is responsible for checking
1305 # `self.left.is_unique and self.right.is_unique`
1306
1307 left_indexer = self.left.get_indexer(target.left)
1308 right_indexer = self.right.get_indexer(target.right)
1309 indexer = np.where(left_indexer == right_indexer, left_indexer, -1)
1310 return indexer
1311
1312 def _get_indexer_monotonic(self, target: Index) -> npt.NDArray[np.intp]:
1313 """
1314 Use searchsorted on endpoints for O(n*log(m)) scalar lookups on
1315 a monotonic non-overlapping IntervalIndex, instead of IntervalTree
1316 which scales poorly for large target arrays. See GH#47614.
1317 """
1318 # Caller is responsible for checking self.is_monotonic_increasing
1319 closed_right = self.closed in ("right", "both")
1320 closed_left = self.closed in ("left", "both")
1321
1322 # searchsorted on right endpoints to find candidate bin
1323 side: Literal["left", "right"] = "left" if closed_right else "right"
1324 indexer = self.right.searchsorted(target, side=side)
1325
1326 nbins = len(self)
1327 past_end = indexer >= nbins
1328 indexer = np.minimum(indexer, nbins - 1)
1329
1330 # Verify values fall within the candidate bin's left bound
1331 left_values = self.left[indexer]
1332 if closed_left:
1333 left_miss = target < left_values
1334 else:
1335 left_miss = target <= left_values
1336
1337 na_mask = isna(target)
1338 invalid = past_end | left_miss | na_mask
1339 indexer = np.where(invalid, -1, indexer)
1340 return ensure_platform_int(indexer)
1341
1342 def _get_indexer_pointwise(
1343 self, target: Index
1344 ) -> tuple[npt.NDArray[np.intp], npt.NDArray[np.intp]]:
1345 """
1346 pointwise implementation for get_indexer and get_indexer_non_unique.
1347 """
1348 indexer, missing = [], []
1349 for i, key in enumerate(target):
1350 try:
1351 locs = self.get_loc(key)
1352 if isinstance(locs, slice):
1353 # Only needed for get_indexer_non_unique
1354 locs = np.arange(locs.start, locs.stop, locs.step, dtype="intp")
1355 elif lib.is_integer(locs):
1356 locs = np.array(locs, ndmin=1)
1357 else:
1358 # otherwise we have ndarray[bool]
1359 locs = np.where(locs)[0] # type: ignore[arg-type]
1360 except KeyError:
1361 missing.append(i)
1362 locs = np.array([-1])
1363 except InvalidIndexError:
1364 # i.e. non-scalar key e.g. a tuple.
1365 # see test_append_different_columns_types_raises
1366 missing.append(i)
1367 locs = np.array([-1])
1368
1369 indexer.append(locs)
1370
1371 concatenated_indexer = np.concatenate(indexer)
1372 return ensure_platform_int(concatenated_indexer), ensure_platform_int(missing)
1373
1374 @cache_readonly
1375 def _index_as_unique(self) -> bool:
1376 return not self.is_overlapping and self._engine._na_count < 2
1377
1378 _requires_unique_msg = (
1379 "cannot handle overlapping indices; use IntervalIndex.get_indexer_non_unique"
1380 )
1381
1382 def _convert_slice_indexer(self, key: slice, kind: Literal["loc", "getitem"]):
1383 if not (key.step is None or key.step == 1):
1384 # GH#31658 if label-based, we require step == 1,
1385 # if positional, we disallow float start/stop
1386 msg = "label-based slicing with step!=1 is not supported for IntervalIndex"
1387 if kind == "loc":
1388 raise ValueError(msg)
1389 if kind == "getitem":
1390 if not is_valid_positional_slice(key):
1391 # i.e. this cannot be interpreted as a positional slice
1392 raise ValueError(msg)
1393
1394 return super()._convert_slice_indexer(key, kind)
1395
1396 @cache_readonly
1397 def _should_fallback_to_positional(self) -> bool:
1398 # integer lookups in Series.__getitem__ are unambiguously
1399 # positional in this case
1400 # error: Item "ExtensionDtype"/"dtype[Any]" of "Union[dtype[Any],
1401 # ExtensionDtype]" has no attribute "subtype"
1402 return self.dtype.subtype.kind in "mM" # type: ignore[union-attr]
1403
1404 def _maybe_cast_slice_bound(self, label, side: str):
1405 return getattr(self, side)._maybe_cast_slice_bound(label, side)
1406
1407 def _is_comparable_dtype(self, dtype: DtypeObj) -> bool:
1408 if not isinstance(dtype, IntervalDtype):
1409 return False
1410 common_subtype = find_common_type([self.dtype, dtype])
1411 return not is_object_dtype(common_subtype)
1412
1413 # --------------------------------------------------------------------
1414
1415 @cache_readonly
1416 def left(self) -> Index:
1417 """
1418 Return left bounds of the intervals in the IntervalIndex.
1419
1420 The left bounds of each interval in the IntervalIndex are
1421 returned as an Index. The datatype of the left bounds is the
1422 same as the datatype of the endpoints of the intervals.
1423
1424 Returns
1425 -------
1426 Index
1427 An Index containing the left bounds of the intervals.
1428
1429 See Also
1430 --------
1431 IntervalIndex.right : Return the right bounds of the intervals
1432 in the IntervalIndex.
1433 IntervalIndex.mid : Return the mid-point of the intervals in
1434 the IntervalIndex.
1435 IntervalIndex.length : Return the length of the intervals in
1436 the IntervalIndex.
1437
1438 Examples
1439 --------
1440 >>> iv_idx = pd.IntervalIndex.from_arrays([1, 2, 3], [4, 5, 6], closed="right")
1441 >>> iv_idx.left
1442 Index([1, 2, 3], dtype='int64')
1443
1444 >>> iv_idx = pd.IntervalIndex.from_tuples(
1445 ... [(1, 4), (2, 5), (3, 6)], closed="left"
1446 ... )
1447 >>> iv_idx.left
1448 Index([1, 2, 3], dtype='int64')
1449 """
1450 return Index(self._data.left, copy=False)
1451
1452 @cache_readonly
1453 def right(self) -> Index:
1454 """
1455 Return right bounds of the intervals in the IntervalIndex.
1456
1457 The right bounds of each interval in the IntervalIndex are
1458 returned as an Index. The datatype of the right bounds is the
1459 same as the datatype of the endpoints of the intervals.
1460
1461 Returns
1462 -------
1463 Index
1464 An Index containing the right bounds of the intervals.
1465
1466 See Also
1467 --------
1468 IntervalIndex.left : Return the left bounds of the intervals
1469 in the IntervalIndex.
1470 IntervalIndex.mid : Return the mid-point of the intervals in
1471 the IntervalIndex.
1472 IntervalIndex.length : Return the length of the intervals in
1473 the IntervalIndex.
1474
1475 Examples
1476 --------
1477 >>> iv_idx = pd.IntervalIndex.from_arrays([1, 2, 3], [4, 5, 6], closed="right")
1478 >>> iv_idx.right
1479 Index([4, 5, 6], dtype='int64')
1480
1481 >>> iv_idx = pd.IntervalIndex.from_tuples(
1482 ... [(1, 4), (2, 5), (3, 6)], closed="left"
1483 ... )
1484 >>> iv_idx.right
1485 Index([4, 5, 6], dtype='int64')
1486 """
1487 return Index(self._data.right, copy=False)
1488
1489 @cache_readonly
1490 def mid(self) -> Index:
1491 """
1492 Return the midpoint of each interval in the IntervalIndex as an Index.
1493
1494 Each midpoint is calculated as the average of the left and right bounds
1495 of each interval. The midpoints are returned as a pandas Index object.
1496
1497 Returns
1498 -------
1499 pandas.Index
1500 An Index containing the midpoints of each interval.
1501
1502 See Also
1503 --------
1504 IntervalIndex.left : Return the left bounds of the intervals
1505 in the IntervalIndex.
1506 IntervalIndex.right : Return the right bounds of the intervals
1507 in the IntervalIndex.
1508 IntervalIndex.length : Return the length of the intervals in
1509 the IntervalIndex.
1510
1511 Notes
1512 -----
1513 The midpoint is the average of the interval bounds, potentially resulting
1514 in a floating-point number even if bounds are integers. The returned Index
1515 will have a dtype that accurately holds the midpoints. This computation is
1516 the same regardless of whether intervals are open or closed.
1517
1518 Examples
1519 --------
1520 >>> iv_idx = pd.IntervalIndex.from_arrays([1, 2, 3], [4, 5, 6])
1521 >>> iv_idx.mid
1522 Index([2.5, 3.5, 4.5], dtype='float64')
1523
1524 >>> iv_idx = pd.IntervalIndex.from_tuples([(1, 4), (2, 5), (3, 6)])
1525 >>> iv_idx.mid
1526 Index([2.5, 3.5, 4.5], dtype='float64')
1527 """
1528 return Index(self._data.mid, copy=False)
1529
1530 @property
1531 def length(self) -> Index:
1532 """
1533 Calculate the length of each interval in the IntervalIndex.
1534
1535 This method returns a new Index containing the lengths of each interval
1536 in the IntervalIndex. The length of an interval is defined as the difference
1537 between its end and its start.
1538
1539 Returns
1540 -------
1541 Index
1542 An Index containing the lengths of each interval.
1543
1544 See Also
1545 --------
1546 Interval.length : Return the length of the Interval.
1547
1548 Examples
1549 --------
1550 >>> intervals = pd.IntervalIndex.from_arrays(
1551 ... [1, 2, 3], [4, 5, 6], closed="right"
1552 ... )
1553 >>> intervals.length
1554 Index([3, 3, 3], dtype='int64')
1555
1556 >>> intervals = pd.IntervalIndex.from_tuples([(1, 5), (6, 10), (11, 15)])
1557 >>> intervals.length
1558 Index([4, 4, 4], dtype='int64')
1559 """
1560 return Index(self._data.length, copy=False)
1561
1562 # --------------------------------------------------------------------
1563 # Set Operations
1564
1565 def _intersection(self, other, sort: bool = False):
1566 """
1567 intersection specialized to the case with matching dtypes.
1568 """
1569 # For IntervalIndex we also know other.closed == self.closed
1570 if self.left.is_unique and self.right.is_unique:
1571 taken = self._intersection_unique(other)
1572 elif other.left.is_unique and other.right.is_unique and self.isna().sum() <= 1:
1573 # Swap other/self if other is unique and self does not have
1574 # multiple NaNs
1575 taken = other._intersection_unique(self)
1576 else:
1577 # duplicates
1578 taken = self._intersection_non_unique(other)
1579
1580 if sort:
1581 taken = taken.sort_values()
1582
1583 return taken
1584
1585 def _intersection_unique(self, other: IntervalIndex) -> IntervalIndex:
1586 """
1587 Used when the IntervalIndex does not have any common endpoint,
1588 no matter left or right.
1589 Return the intersection with another IntervalIndex.
1590 Parameters
1591 ----------
1592 other : IntervalIndex
1593 Returns
1594 -------
1595 IntervalIndex
1596 """
1597 # Note: this is much more performant than super()._intersection(other)
1598 lindexer = self.left.get_indexer(other.left)
1599 rindexer = self.right.get_indexer(other.right)
1600
1601 match = (lindexer == rindexer) & (lindexer != -1)
1602 indexer = lindexer.take(match.nonzero()[0])
1603 indexer = unique(indexer)
1604
1605 return self.take(indexer)
1606
1607 def _intersection_non_unique(self, other: IntervalIndex) -> IntervalIndex:
1608 """
1609 Used when the IntervalIndex does have some common endpoints,
1610 on either sides.
1611 Return the intersection with another IntervalIndex.
1612
1613 Parameters
1614 ----------
1615 other : IntervalIndex
1616
1617 Returns
1618 -------
1619 IntervalIndex
1620 """
1621 # Note: this is about 3.25x faster than super()._intersection(other)
1622 # in IntervalIndexMethod.time_intersection_both_duplicate(1000)
1623 mask = np.zeros(len(self), dtype=bool)
1624
1625 if self.hasnans and other.hasnans:
1626 first_nan_loc = np.arange(len(self))[self.isna()][0]
1627 mask[first_nan_loc] = True
1628
1629 other_tups = set(zip(other.left, other.right, strict=True))
1630 for i, tup in enumerate(zip(self.left, self.right, strict=True)):
1631 if tup in other_tups:
1632 mask[i] = True
1633
1634 return self[mask]
1635
1636 # --------------------------------------------------------------------
1637
1638 def _get_engine_target(self) -> np.ndarray:
1639 # Note: we _could_ use libjoin functions by either casting to object
1640 # dtype or constructing tuples (faster than constructing Intervals)
1641 # but the libjoin fastpaths are no longer fast in these cases.
1642 raise NotImplementedError(
1643 "IntervalIndex does not use libjoin fastpaths or pass values to "
1644 "IndexEngine objects"
1645 )
1646
1647 def _from_join_target(self, result):
1648 raise NotImplementedError("IntervalIndex does not use libjoin fastpaths")
1649
1650 # TODO: arithmetic operations
1651
1652
1653def _is_valid_endpoint(endpoint) -> bool:
1654 """
1655 Helper for interval_range to check if start/end are valid types.
1656 """
1657 return any(
1658 [
1659 is_number(endpoint),
1660 isinstance(endpoint, Timestamp),
1661 isinstance(endpoint, Timedelta),
1662 endpoint is None,
1663 ]
1664 )
1665
1666
1667def _is_type_compatible(a, b) -> bool:
1668 """
1669 Helper for interval_range to check type compat of start/end/freq.
1670 """
1671 is_ts_compat = lambda x: isinstance(x, (Timestamp, BaseOffset))
1672 is_td_compat = lambda x: isinstance(x, (Timedelta, BaseOffset))
1673 return (
1674 (is_number(a) and is_number(b))
1675 or (is_ts_compat(a) and is_ts_compat(b))
1676 or (is_td_compat(a) and is_td_compat(b))
1677 or com.any_none(a, b)
1678 )
1679
1680
1681@set_module("pandas")
1682def interval_range(
1683 start=None,
1684 end=None,
1685 periods=None,
1686 freq=None,
1687 name: Hashable | None = None,
1688 closed: IntervalClosedType = "right",
1689) -> IntervalIndex:
1690 """
1691 Return a fixed frequency IntervalIndex.
1692
1693 This function generates a sequence of evenly spaced intervals over a
1694 specified range, supporting numeric, datetime-like, and timedelta
1695 endpoints.
1696
1697 Parameters
1698 ----------
1699 start : numeric or datetime-like, default None
1700 Left bound for generating intervals.
1701 end : numeric or datetime-like, default None
1702 Right bound for generating intervals.
1703 periods : int, default None
1704 Number of periods to generate.
1705 freq : numeric, str, Timedelta, datetime.timedelta, or DateOffset, default None
1706 The length of each interval. Must be consistent with the type of start
1707 and end, e.g. 2 for numeric, or '5H' for datetime-like. Default is 1
1708 for numeric and 'D' for datetime-like.
1709 name : str, default None
1710 Name of the resulting IntervalIndex.
1711 closed : {'left', 'right', 'both', 'neither'}, default 'right'
1712 Whether the intervals are closed on the left-side, right-side, both
1713 or neither.
1714
1715 Returns
1716 -------
1717 IntervalIndex
1718 Object with a fixed frequency.
1719
1720 See Also
1721 --------
1722 IntervalIndex : An Index of intervals that are all closed on the same side.
1723
1724 Notes
1725 -----
1726 Of the four parameters ``start``, ``end``, ``periods``, and ``freq``,
1727 exactly three must be specified. If ``freq`` is omitted, the resulting
1728 ``IntervalIndex`` will have ``periods`` linearly spaced elements between
1729 ``start`` and ``end``, inclusively.
1730
1731 To learn more about datetime-like frequency strings, please see
1732 :ref:`this link<timeseries.offset_aliases>`.
1733
1734 Examples
1735 --------
1736 Numeric ``start`` and ``end`` is supported.
1737
1738 >>> pd.interval_range(start=0, end=5)
1739 IntervalIndex([(0, 1], (1, 2], (2, 3], (3, 4], (4, 5]],
1740 dtype='interval[int64, right]')
1741
1742 Additionally, datetime-like input is also supported.
1743
1744 >>> pd.interval_range(
1745 ... start=pd.Timestamp("2017-01-01"), end=pd.Timestamp("2017-01-04")
1746 ... )
1747 IntervalIndex([(2017-01-01 00:00:00, 2017-01-02 00:00:00],
1748 (2017-01-02 00:00:00, 2017-01-03 00:00:00],
1749 (2017-01-03 00:00:00, 2017-01-04 00:00:00]],
1750 dtype='interval[datetime64[us], right]')
1751
1752 The ``freq`` parameter specifies the frequency between the left and right.
1753 endpoints of the individual intervals within the ``IntervalIndex``. For
1754 numeric ``start`` and ``end``, the frequency must also be numeric.
1755
1756 >>> pd.interval_range(start=0, periods=4, freq=1.5)
1757 IntervalIndex([(0.0, 1.5], (1.5, 3.0], (3.0, 4.5], (4.5, 6.0]],
1758 dtype='interval[float64, right]')
1759
1760 Similarly, for datetime-like ``start`` and ``end``, the frequency must be
1761 convertible to a DateOffset.
1762
1763 >>> pd.interval_range(start=pd.Timestamp("2017-01-01"), periods=3, freq="MS")
1764 IntervalIndex([(2017-01-01 00:00:00, 2017-02-01 00:00:00],
1765 (2017-02-01 00:00:00, 2017-03-01 00:00:00],
1766 (2017-03-01 00:00:00, 2017-04-01 00:00:00]],
1767 dtype='interval[datetime64[us], right]')
1768
1769 Specify ``start``, ``end``, and ``periods``; the frequency is generated
1770 automatically (linearly spaced).
1771
1772 >>> pd.interval_range(start=0, end=6, periods=4)
1773 IntervalIndex([(0.0, 1.5], (1.5, 3.0], (3.0, 4.5], (4.5, 6.0]],
1774 dtype='interval[float64, right]')
1775
1776 The ``closed`` parameter specifies which endpoints of the individual
1777 intervals within the ``IntervalIndex`` are closed.
1778
1779 >>> pd.interval_range(end=5, periods=4, closed="both")
1780 IntervalIndex([[1, 2], [2, 3], [3, 4], [4, 5]],
1781 dtype='interval[int64, both]')
1782 """
1783 start = maybe_box_datetimelike(start)
1784 end = maybe_box_datetimelike(end)
1785 endpoint = start if start is not None else end
1786
1787 if freq is None and com.any_none(periods, start, end):
1788 freq = 1 if is_number(endpoint) else "D"
1789
1790 if com.count_not_none(start, end, periods, freq) != 3:
1791 raise ValueError(
1792 "Of the four parameters: start, end, periods, and "
1793 "freq, exactly three must be specified"
1794 )
1795
1796 if not _is_valid_endpoint(start):
1797 raise ValueError(f"start must be numeric or datetime-like, got {start}")
1798 if not _is_valid_endpoint(end):
1799 raise ValueError(f"end must be numeric or datetime-like, got {end}")
1800
1801 periods = validate_periods(periods)
1802
1803 if freq is not None and not is_number(freq):
1804 try:
1805 freq = to_offset(freq)
1806 except ValueError as err:
1807 raise ValueError(
1808 f"freq must be numeric or convertible to DateOffset, got {freq}"
1809 ) from err
1810
1811 # verify type compatibility
1812 if not all(
1813 [
1814 _is_type_compatible(start, end),
1815 _is_type_compatible(start, freq),
1816 _is_type_compatible(end, freq),
1817 ]
1818 ):
1819 raise TypeError("start, end, freq need to be type compatible")
1820
1821 # +1 to convert interval count to breaks count (n breaks = n-1 intervals)
1822 if periods is not None:
1823 periods += 1
1824
1825 breaks: np.ndarray | TimedeltaIndex | DatetimeIndex
1826
1827 if is_number(endpoint):
1828 dtype: np.dtype = np.dtype("int64")
1829 if com.all_not_none(start, end, freq):
1830 if (
1831 isinstance(start, (np.integer, np.floating))
1832 and isinstance(end, (np.integer, np.floating))
1833 and start.dtype == end.dtype
1834 ):
1835 dtype = start.dtype
1836 elif (
1837 isinstance(start, (float, np.floating))
1838 or isinstance(end, (float, np.floating))
1839 or isinstance(freq, (float, np.floating))
1840 ):
1841 dtype = np.dtype("float64")
1842 # 0.1 ensures we capture end
1843 breaks = np.arange(start, end + (freq * 0.1), freq)
1844 breaks = maybe_downcast_numeric(breaks, dtype)
1845 else:
1846 # compute the period/start/end if unspecified (at most one)
1847 if periods is None:
1848 periods = int((end - start) // freq) + 1
1849 elif start is None:
1850 start = end - (periods - 1) * freq
1851 elif end is None:
1852 end = start + (periods - 1) * freq
1853
1854 breaks = np.linspace(start, end, periods)
1855 if all(is_integer(x) for x in com.not_none(start, end, freq)):
1856 # np.linspace always produces float output
1857 breaks = maybe_downcast_numeric(breaks, dtype)
1858 # delegate to the appropriate range function
1859 elif isinstance(endpoint, Timestamp):
1860 breaks = date_range(start=start, end=end, periods=periods, freq=freq)
1861 else:
1862 breaks = timedelta_range(start=start, end=end, periods=periods, freq=freq)
1863
1864 return IntervalIndex.from_breaks(
1865 breaks,
1866 name=name,
1867 closed=closed,
1868 dtype=IntervalDtype(subtype=breaks.dtype, closed=closed),
1869 )