1from __future__ import annotations
2
3import operator
4from operator import (
5 le,
6 lt,
7)
8import textwrap
9from typing import (
10 TYPE_CHECKING,
11 Literal,
12 Self,
13 TypeAlias,
14 overload,
15)
16
17import numpy as np
18
19from pandas._libs import lib
20from pandas._libs.interval import (
21 VALID_CLOSED,
22 Interval,
23 IntervalMixin,
24 intervals_to_interval_bounds,
25)
26from pandas._libs.missing import NA
27from pandas._typing import (
28 ArrayLike,
29 AxisInt,
30 Dtype,
31 IntervalClosedType,
32 NpDtype,
33 PositionalIndexer,
34 ScalarIndexer,
35 SequenceIndexer,
36 SortKind,
37 TimeArrayLike,
38 npt,
39)
40from pandas.compat.numpy import function as nv
41from pandas.errors import IntCastingNaNError
42from pandas.util._decorators import set_module
43
44from pandas.core.dtypes.cast import (
45 LossySetitemError,
46 maybe_upcast_numeric_to_64bit,
47)
48from pandas.core.dtypes.common import (
49 is_float_dtype,
50 is_integer_dtype,
51 is_list_like,
52 is_object_dtype,
53 is_scalar,
54 is_string_dtype,
55 needs_i8_conversion,
56 pandas_dtype,
57)
58from pandas.core.dtypes.dtypes import (
59 CategoricalDtype,
60 IntervalDtype,
61)
62from pandas.core.dtypes.generic import (
63 ABCDataFrame,
64 ABCDatetimeIndex,
65 ABCIntervalIndex,
66 ABCPeriodIndex,
67)
68from pandas.core.dtypes.missing import (
69 is_valid_na_for_dtype,
70 isna,
71 notna,
72)
73
74from pandas.core.algorithms import (
75 isin,
76 take,
77 unique,
78)
79from pandas.core.arrays import ArrowExtensionArray
80from pandas.core.arrays.base import (
81 ExtensionArray,
82)
83from pandas.core.arrays.datetimes import DatetimeArray
84from pandas.core.arrays.timedeltas import TimedeltaArray
85import pandas.core.common as com
86from pandas.core.construction import (
87 array as pd_array,
88 ensure_wrapped_if_datetimelike,
89 extract_array,
90)
91from pandas.core.indexers import (
92 check_array_indexer,
93 getitem_returns_view,
94)
95from pandas.core.ops import (
96 invalid_comparison,
97 unpack_zerodim_and_defer,
98)
99
100if TYPE_CHECKING:
101 from collections.abc import (
102 Callable,
103 Iterator,
104 Sequence,
105 )
106
107 from pandas import (
108 Index,
109 )
110
111
112IntervalSide: TypeAlias = TimeArrayLike | np.ndarray
113IntervalOrNA: TypeAlias = Interval | float
114
115_interval_shared_docs: dict[str, str] = {}
116
117_shared_docs_kwargs = {
118 "klass": "IntervalArray",
119 "qualname": "arrays.IntervalArray",
120 "name": "",
121}
122
123
124_interval_shared_docs["class"] = """
125%(summary)s
126
127Parameters
128----------
129data : array-like (1-dimensional)
130 Array-like (ndarray, :class:`DateTimeArray`, :class:`TimeDeltaArray`) containing
131 Interval objects from which to build the %(klass)s.
132closed : {'left', 'right', 'both', 'neither'}, default 'right'
133 Whether the intervals are closed on the left-side, right-side, both or
134 neither.
135dtype : dtype or None, default None
136 If None, dtype will be inferred.
137copy : bool, default False
138 Copy the input data.
139%(name)s\
140verify_integrity : bool, default True
141 Verify that the %(klass)s is valid.
142
143Attributes
144----------
145left
146right
147closed
148mid
149length
150is_empty
151is_non_overlapping_monotonic
152%(extra_attributes)s\
153
154Methods
155-------
156from_arrays
157from_tuples
158from_breaks
159contains
160overlaps
161set_closed
162to_tuples
163%(extra_methods)s\
164
165See Also
166--------
167Index : The base pandas Index type.
168Interval : A bounded slice-like interval; the elements of an %(klass)s.
169interval_range : Function to create a fixed frequency IntervalIndex.
170cut : Bin values into discrete Intervals.
171qcut : Bin values into equal-sized Intervals based on rank or sample quantiles.
172
173Notes
174-----
175See the `user guide
176<https://pandas.pydata.org/pandas-docs/stable/user_guide/advanced.html#intervalindex>`__
177for more.
178
179%(examples)s\
180"""
181
182
183@set_module("pandas.arrays")
184class IntervalArray(IntervalMixin, ExtensionArray):
185 """
186 Pandas array for interval data that are closed on the same side.
187
188 Parameters
189 ----------
190 data : array-like (1-dimensional)
191 Array-like (ndarray, :class:`DateTimeArray`, :class:`TimeDeltaArray`) containing
192 Interval objects from which to build the IntervalArray.
193 closed : {'left', 'right', 'both', 'neither'}, default 'right'
194 Whether the intervals are closed on the left-side, right-side, both or
195 neither.
196 dtype : dtype or None, default None
197 If None, dtype will be inferred.
198 copy : bool, default False
199 Copy the input data.
200 verify_integrity : bool, default True
201 Verify that the IntervalArray is valid.
202
203 Attributes
204 ----------
205 left
206 right
207 closed
208 mid
209 length
210 is_empty
211 is_non_overlapping_monotonic
212
213 Methods
214 -------
215 from_arrays
216 from_tuples
217 from_breaks
218 contains
219 overlaps
220 set_closed
221 to_tuples
222
223 See Also
224 --------
225 Index : The base pandas Index type.
226 Interval : A bounded slice-like interval; the elements of an IntervalArray.
227 interval_range : Function to create a fixed frequency IntervalIndex.
228 cut : Bin values into discrete Intervals.
229 qcut : Bin values into equal-sized Intervals based on rank or sample quantiles.
230
231 Notes
232 -----
233 See the `user guide
234 <https://pandas.pydata.org/pandas-docs/stable/user_guide/advanced.html#intervalindex>`__
235 for more.
236
237 Examples
238 --------
239 A new ``IntervalArray`` can be constructed directly from an array-like of
240 ``Interval`` objects:
241 >>> pd.arrays.IntervalArray([pd.Interval(0, 1), pd.Interval(1, 5)])
242 <IntervalArray>
243 [(0, 1], (1, 5]]
244 Length: 2, dtype: interval[int64, right]
245
246 It may also be constructed using one of the constructor
247 methods: :meth:`IntervalArray.from_arrays`,
248 :meth:`IntervalArray.from_breaks`, and :meth:`IntervalArray.from_tuples`.
249 """
250
251 can_hold_na = True
252 _na_value = _fill_value = np.nan
253
254 @property
255 def ndim(self) -> Literal[1]:
256 return 1
257
258 # To make mypy recognize the fields
259 _left: IntervalSide
260 _right: IntervalSide
261 _dtype: IntervalDtype
262
263 # ---------------------------------------------------------------------
264 # Constructors
265
266 def __new__(
267 cls,
268 data,
269 closed: IntervalClosedType | None = None,
270 dtype: Dtype | None = None,
271 copy: bool = False,
272 verify_integrity: bool = True,
273 ) -> Self:
274 data = extract_array(data, extract_numpy=True)
275
276 if isinstance(data, cls):
277 left: IntervalSide = data._left
278 right: IntervalSide = data._right
279 closed = closed or data.closed
280 dtype = IntervalDtype(left.dtype, closed=closed)
281 else:
282 # don't allow scalars
283 if is_scalar(data):
284 msg = (
285 f"{cls.__name__}(...) must be called with a collection "
286 f"of some kind, {data} was passed"
287 )
288 raise TypeError(msg)
289
290 # might need to convert empty or purely na data
291 data = _maybe_convert_platform_interval(data)
292 left, right, infer_closed = intervals_to_interval_bounds(
293 data, validate_closed=closed is None
294 )
295 if left.dtype == object:
296 left = lib.maybe_convert_objects(left)
297 right = lib.maybe_convert_objects(right)
298 closed = closed or infer_closed
299
300 left, right, dtype = cls._ensure_simple_new_inputs(
301 left,
302 right,
303 closed=closed,
304 copy=copy,
305 dtype=dtype,
306 )
307
308 if verify_integrity:
309 cls._validate(left, right, dtype=dtype)
310
311 return cls._simple_new(
312 left,
313 right,
314 dtype=dtype,
315 )
316
317 @classmethod
318 def _simple_new(
319 cls,
320 left: IntervalSide,
321 right: IntervalSide,
322 dtype: IntervalDtype,
323 ) -> Self:
324 result = IntervalMixin.__new__(cls)
325 result._left = left
326 result._right = right
327 result._dtype = dtype
328
329 return result
330
331 @classmethod
332 def _ensure_simple_new_inputs(
333 cls,
334 left,
335 right,
336 closed: IntervalClosedType | None = None,
337 copy: bool = False,
338 dtype: Dtype | None = None,
339 ) -> tuple[IntervalSide, IntervalSide, IntervalDtype]:
340 """Ensure correctness of input parameters for cls._simple_new."""
341 from pandas.core.indexes.base import ensure_index
342
343 left = ensure_index(left, copy=copy)
344 left = maybe_upcast_numeric_to_64bit(left)
345
346 right = ensure_index(right, copy=copy)
347 right = maybe_upcast_numeric_to_64bit(right)
348
349 if closed is None and isinstance(dtype, IntervalDtype):
350 closed = dtype.closed
351
352 closed = closed or "right"
353
354 if dtype is not None:
355 # GH 19262: dtype must be an IntervalDtype to override inferred
356 dtype = pandas_dtype(dtype)
357 if isinstance(dtype, IntervalDtype):
358 if dtype.subtype is not None:
359 left = left.astype(dtype.subtype)
360 right = right.astype(dtype.subtype)
361 else:
362 msg = f"dtype must be an IntervalDtype, got {dtype}"
363 raise TypeError(msg)
364
365 if dtype.closed is None:
366 # possibly loading an old pickle
367 dtype = IntervalDtype(dtype.subtype, closed)
368 elif closed != dtype.closed:
369 raise ValueError("closed keyword does not match dtype.closed")
370
371 # coerce dtypes to match if needed
372 if is_float_dtype(left.dtype) and is_integer_dtype(right.dtype):
373 right = right.astype(left.dtype)
374 elif is_float_dtype(right.dtype) and is_integer_dtype(left.dtype):
375 left = left.astype(right.dtype)
376
377 if type(left) != type(right):
378 msg = (
379 f"must not have differing left [{type(left).__name__}] and "
380 f"right [{type(right).__name__}] types"
381 )
382 raise ValueError(msg)
383 if isinstance(left.dtype, CategoricalDtype) or is_string_dtype(left.dtype):
384 # GH 19016
385 msg = (
386 "category, object, and string subtypes are not supported "
387 "for IntervalArray"
388 )
389 raise TypeError(msg)
390 if isinstance(left, ABCPeriodIndex):
391 msg = "Period dtypes are not supported, use a PeriodIndex instead"
392 raise ValueError(msg)
393 if isinstance(left, ABCDatetimeIndex) and str(left.tz) != str(right.tz):
394 msg = (
395 "left and right must have the same time zone, got "
396 f"'{left.tz}' and '{right.tz}'"
397 )
398 raise ValueError(msg)
399 elif needs_i8_conversion(left.dtype) and left.unit != right.unit:
400 # e.g. m8[s] vs m8[ms], try to cast to a common dtype GH#55714
401 left_arr, right_arr = left._data._ensure_matching_resos(right._data)
402 left = ensure_index(left_arr)
403 right = ensure_index(right_arr)
404
405 # For dt64/td64 we want DatetimeArray/TimedeltaArray instead of ndarray
406 left = ensure_wrapped_if_datetimelike(left)
407 left = extract_array(left, extract_numpy=True)
408 right = ensure_wrapped_if_datetimelike(right)
409 right = extract_array(right, extract_numpy=True)
410
411 if isinstance(left, ArrowExtensionArray) or isinstance(
412 right, ArrowExtensionArray
413 ):
414 pass
415 else:
416 lbase = getattr(left, "_ndarray", left)
417 lbase = getattr(lbase, "_data", lbase).base
418 rbase = getattr(right, "_ndarray", right)
419 rbase = getattr(rbase, "_data", rbase).base
420 if lbase is not None and lbase is rbase:
421 # If these share data, then setitem could corrupt our IA
422 right = right.copy()
423
424 dtype = IntervalDtype(left.dtype, closed=closed)
425
426 # Check for mismatched signed/unsigned integer dtypes after casting
427 left_dtype = left.dtype
428 right_dtype = right.dtype
429 if (
430 left_dtype.kind in "iu"
431 and right_dtype.kind in "iu"
432 and left_dtype.kind != right_dtype.kind
433 ):
434 raise TypeError(
435 f"Left and right arrays must have matching signedness. "
436 f"Got {left_dtype} and {right_dtype}."
437 )
438 return left, right, dtype
439
440 @classmethod
441 def _from_sequence(
442 cls,
443 scalars,
444 *,
445 dtype: Dtype | None = None,
446 copy: bool = False,
447 ) -> Self:
448 return cls(scalars, dtype=dtype, copy=copy)
449
450 @classmethod
451 def _from_factorized(cls, values: np.ndarray, original: IntervalArray) -> Self:
452 return cls._from_sequence(values, dtype=original.dtype)
453
454 _interval_shared_docs["from_breaks"] = textwrap.dedent(
455 """
456 Construct an %(klass)s from an array of splits.
457
458 Parameters
459 ----------
460 breaks : array-like (1-dimensional)
461 Left and right bounds for each interval.
462 closed : {'left', 'right', 'both', 'neither'}, default 'right'
463 Whether the intervals are closed on the left-side, right-side, both
464 or neither.\
465 %(name)s
466 copy : bool, default False
467 Copy the data.
468 dtype : dtype or None, default None
469 If None, dtype will be inferred.
470
471 Returns
472 -------
473 %(klass)s
474
475 See Also
476 --------
477 interval_range : Function to create a fixed frequency IntervalIndex.
478 %(klass)s.from_arrays : Construct from a left and right array.
479 %(klass)s.from_tuples : Construct from a sequence of tuples.
480
481 %(examples)s\
482 """
483 )
484
485 @classmethod
486 def from_breaks(
487 cls,
488 breaks,
489 closed: IntervalClosedType | None = "right",
490 copy: bool = False,
491 dtype: Dtype | None = None,
492 ) -> Self:
493 """
494 Construct an IntervalArray from an array of splits.
495
496 Parameters
497 ----------
498 breaks : array-like (1-dimensional)
499 Left and right bounds for each interval.
500 closed : {'left', 'right', 'both', 'neither'}, default 'right'
501 Whether the intervals are closed on the left-side, right-side, both
502 or neither.
503 copy : bool, default False
504 Copy the data.
505 dtype : dtype or None, default None
506 If None, dtype will be inferred.
507
508 Returns
509 -------
510 IntervalArray
511
512 See Also
513 --------
514 interval_range : Function to create a fixed frequency IntervalIndex.
515 IntervalArray.from_arrays : Construct from a left and right array.
516 IntervalArray.from_tuples : Construct from a sequence of tuples.
517
518 Examples
519 --------
520 >>> pd.arrays.IntervalArray.from_breaks([0, 1, 2, 3])
521 <IntervalArray>
522 [(0, 1], (1, 2], (2, 3]]
523 Length: 3, dtype: interval[int64, right]
524 """
525
526 breaks = _maybe_convert_platform_interval(breaks)
527
528 return cls.from_arrays(breaks[:-1], breaks[1:], closed, copy=copy, dtype=dtype)
529
530 _interval_shared_docs["from_arrays"] = textwrap.dedent(
531 """
532 Construct from two arrays defining the left and right bounds.
533
534 Parameters
535 ----------
536 left : array-like (1-dimensional)
537 Left bounds for each interval.
538 right : array-like (1-dimensional)
539 Right bounds for each interval.
540 closed : {'left', 'right', 'both', 'neither'}, default 'right'
541 Whether the intervals are closed on the left-side, right-side, both
542 or neither.\
543 %(name)s
544 copy : bool, default False
545 Copy the data.
546 dtype : dtype, optional
547 If None, dtype will be inferred.
548
549 Returns
550 -------
551 %(klass)s
552
553 Raises
554 ------
555 ValueError
556 When a value is missing in only one of `left` or `right`.
557 When a value in `left` is greater than the corresponding value
558 in `right`.
559
560 See Also
561 --------
562 interval_range : Function to create a fixed frequency IntervalIndex.
563 %(klass)s.from_breaks : Construct an %(klass)s from an array of
564 splits.
565 %(klass)s.from_tuples : Construct an %(klass)s from an
566 array-like of tuples.
567
568 Notes
569 -----
570 Each element of `left` must be less than or equal to the `right`
571 element at the same position. If an element is missing, it must be
572 missing in both `left` and `right`. A TypeError is raised when
573 using an unsupported type for `left` or `right`. At the moment,
574 'category', 'object', and 'string' subtypes are not supported.
575
576 %(examples)s\
577 """
578 )
579
580 @classmethod
581 def from_arrays(
582 cls,
583 left,
584 right,
585 closed: IntervalClosedType | None = "right",
586 copy: bool = False,
587 dtype: Dtype | None = None,
588 ) -> Self:
589 """
590 Construct from two arrays defining the left and right bounds.
591
592 Parameters
593 ----------
594 left : array-like (1-dimensional)
595 Left bounds for each interval.
596 right : array-like (1-dimensional)
597 Right bounds for each interval.
598 closed : {'left', 'right', 'both', 'neither'}, default 'right'
599 Whether the intervals are closed on the left-side, right-side, both
600 or neither.
601 copy : bool, default False
602 Copy the data.
603 dtype : dtype, optional
604 If None, dtype will be inferred.
605
606 Returns
607 -------
608 IntervalArray
609
610 Raises
611 ------
612 ValueError
613 When a value is missing in only one of `left` or `right`.
614 When a value in `left` is greater than the corresponding value
615 in `right`.
616
617 See Also
618 --------
619 interval_range : Function to create a fixed frequency IntervalIndex.
620 IntervalArray.from_breaks : Construct an IntervalArray from an array of
621 splits.
622 IntervalArray.from_tuples : Construct an IntervalArray from an
623 array-like of tuples.
624
625 Notes
626 -----
627 Each element of `left` must be less than or equal to the `right`
628 element at the same position. If an element is missing, it must be
629 missing in both `left` and `right`. A TypeError is raised when
630 using an unsupported type for `left` or `right`. At the moment,
631 'category', 'object', and 'string' subtypes are not supported.
632
633 Examples
634 --------
635 >>> pd.arrays.IntervalArray.from_arrays([0, 1, 2], [1, 2, 3])
636 <IntervalArray>
637 [(0, 1], (1, 2], (2, 3]]
638 Length: 3, dtype: interval[int64, right]
639 """
640 left = _maybe_convert_platform_interval(left)
641 right = _maybe_convert_platform_interval(right)
642
643 left, right, dtype = cls._ensure_simple_new_inputs(
644 left,
645 right,
646 closed=closed,
647 copy=copy,
648 dtype=dtype,
649 )
650 cls._validate(left, right, dtype=dtype)
651
652 return cls._simple_new(left, right, dtype=dtype)
653
654 _interval_shared_docs["from_tuples"] = textwrap.dedent(
655 """
656 Construct an %(klass)s from an array-like of tuples.
657
658 Parameters
659 ----------
660 data : array-like (1-dimensional)
661 Array of tuples.
662 closed : {'left', 'right', 'both', 'neither'}, default 'right'
663 Whether the intervals are closed on the left-side, right-side, both
664 or neither.\
665 %(name)s
666 copy : bool, default False
667 By-default copy the data, this is compat only and ignored.
668 dtype : dtype or None, default None
669 If None, dtype will be inferred.
670
671 Returns
672 -------
673 %(klass)s
674
675 See Also
676 --------
677 interval_range : Function to create a fixed frequency IntervalIndex.
678 %(klass)s.from_arrays : Construct an %(klass)s from a left and
679 right array.
680 %(klass)s.from_breaks : Construct an %(klass)s from an array of
681 splits.
682
683 %(examples)s\
684 """
685 )
686
687 @classmethod
688 def from_tuples(
689 cls,
690 data,
691 closed: IntervalClosedType | None = "right",
692 copy: bool = False,
693 dtype: Dtype | None = None,
694 ) -> Self:
695 """
696 Construct an IntervalArray from an array-like of tuples.
697
698 Parameters
699 ----------
700 data : array-like (1-dimensional)
701 Array of tuples.
702 closed : {'left', 'right', 'both', 'neither'}, default 'right'
703 Whether the intervals are closed on the left-side, right-side, both
704 or neither.
705 copy : bool, default False
706 By-default copy the data, this is compat only and ignored.
707 dtype : dtype or None, default None
708 If None, dtype will be inferred.
709
710 Returns
711 -------
712 IntervalArray
713
714 See Also
715 --------
716 interval_range : Function to create a fixed frequency IntervalIndex.
717 IntervalArray.from_arrays : Construct an IntervalArray from a left and
718 right array.
719 IntervalArray.from_breaks : Construct an IntervalArray from an array of
720 splits.
721
722 Examples
723 --------
724 >>> pd.arrays.IntervalArray.from_tuples([(0, 1), (1, 2)])
725 <IntervalArray>
726 [(0, 1], (1, 2]]
727 Length: 2, dtype: interval[int64, right]
728 """
729 if len(data):
730 left, right = [], []
731 else:
732 # ensure that empty data keeps input dtype
733 left = right = data
734
735 for d in data:
736 if not isinstance(d, tuple) and isna(d):
737 lhs = rhs = np.nan
738 else:
739 name = cls.__name__
740 try:
741 # need list of length 2 tuples, e.g. [(0, 1), (1, 2), ...]
742 lhs, rhs = d
743 except ValueError as err:
744 msg = f"{name}.from_tuples requires tuples of length 2, got {d}"
745 raise ValueError(msg) from err
746 except TypeError as err:
747 msg = f"{name}.from_tuples received an invalid item, {d}"
748 raise TypeError(msg) from err
749 left.append(lhs)
750 right.append(rhs)
751
752 return cls.from_arrays(left, right, closed, copy=False, dtype=dtype)
753
754 @classmethod
755 def _validate(cls, left, right, dtype: IntervalDtype) -> None:
756 """
757 Verify that the IntervalArray is valid.
758
759 Checks that
760
761 * dtype is correct
762 * left and right match lengths
763 * left and right have the same missing values
764 * left is always below right
765 """
766 if not isinstance(dtype, IntervalDtype):
767 msg = f"invalid dtype: {dtype}"
768 raise ValueError(msg)
769 if len(left) != len(right):
770 msg = "left and right must have the same length"
771 raise ValueError(msg)
772 left_mask = notna(left)
773 right_mask = notna(right)
774 if not (left_mask == right_mask).all():
775 msg = (
776 "missing values must be missing in the same "
777 "location both left and right sides"
778 )
779 raise ValueError(msg)
780 if not (left[left_mask] <= right[left_mask]).all():
781 msg = "left side of interval must be <= right side"
782 raise ValueError(msg)
783
784 def _shallow_copy(self, left, right) -> Self:
785 """
786 Return a new IntervalArray with the replacement attributes
787
788 Parameters
789 ----------
790 left : Index
791 Values to be used for the left-side of the intervals.
792 right : Index
793 Values to be used for the right-side of the intervals.
794 """
795 dtype = IntervalDtype(left.dtype, closed=self.closed)
796 left, right, dtype = self._ensure_simple_new_inputs(left, right, dtype=dtype)
797
798 return self._simple_new(left, right, dtype=dtype)
799
800 # ---------------------------------------------------------------------
801 # Descriptive
802
803 @property
804 def dtype(self) -> IntervalDtype:
805 return self._dtype
806
807 @property
808 def nbytes(self) -> int:
809 return self.left.nbytes + self.right.nbytes
810
811 @property
812 def size(self) -> int:
813 # Avoid materializing self.values
814 return self.left.size
815
816 # ---------------------------------------------------------------------
817 # EA Interface
818
819 def __iter__(self) -> Iterator:
820 return iter(np.asarray(self))
821
822 def __len__(self) -> int:
823 return len(self._left)
824
825 @overload
826 def __getitem__(self, key: ScalarIndexer) -> IntervalOrNA: ...
827
828 @overload
829 def __getitem__(self, key: SequenceIndexer) -> Self: ...
830
831 def __getitem__(self, key: PositionalIndexer) -> Self | IntervalOrNA:
832 key = check_array_indexer(self, key)
833 left = self._left[key]
834 right = self._right[key]
835
836 if not isinstance(left, (np.ndarray, ExtensionArray)):
837 # scalar
838 if is_scalar(left) and isna(left):
839 return self._fill_value
840 return Interval(left, right, self.closed)
841 if np.ndim(left) > 1:
842 # GH#30588 multi-dimensional indexer disallowed
843 raise ValueError("multi-dimensional indexing not allowed")
844 # Argument 2 to "_simple_new" of "IntervalArray" has incompatible type
845 # "Union[Period, Timestamp, Timedelta, NaTType, DatetimeArray, TimedeltaArray,
846 # ndarray[Any, Any]]"; expected "Union[Union[DatetimeArray, TimedeltaArray],
847 # ndarray[Any, Any]]"
848 result = self._simple_new(left, right, dtype=self.dtype) # type: ignore[arg-type]
849 if getitem_returns_view(self, key):
850 result._readonly = self._readonly
851 return result
852
853 def __setitem__(self, key, value) -> None:
854 if self._readonly:
855 raise ValueError("Cannot modify read-only array")
856
857 value_left, value_right = self._validate_setitem_value(value)
858 key = check_array_indexer(self, key)
859
860 self._left[key] = value_left
861 self._right[key] = value_right
862
863 def _cmp_method(self, other, op):
864 # ensure pandas array for list-like and eliminate non-interval scalars
865 if is_list_like(other):
866 if len(self) != len(other):
867 raise ValueError("Lengths must match to compare")
868 other = pd_array(other)
869 elif not isinstance(other, Interval):
870 # non-interval scalar -> no matches
871 if other is NA:
872 # GH#31882
873 from pandas.core.arrays import BooleanArray
874
875 arr = np.empty(self.shape, dtype=bool)
876 mask = np.ones(self.shape, dtype=bool)
877 return BooleanArray(arr, mask)
878 return invalid_comparison(self, other, op)
879
880 # determine the dtype of the elements we want to compare
881 if isinstance(other, Interval):
882 other_dtype = pandas_dtype("interval")
883 elif not isinstance(other.dtype, CategoricalDtype):
884 other_dtype = other.dtype
885 else:
886 # for categorical defer to categories for dtype
887 other_dtype = other.categories.dtype
888
889 # extract intervals if we have interval categories with matching closed
890 if isinstance(other_dtype, IntervalDtype):
891 if self.closed != other.categories.closed:
892 return invalid_comparison(self, other, op)
893
894 other = other.categories._values.take(
895 other.codes, allow_fill=True, fill_value=other.categories._na_value
896 )
897
898 # interval-like -> need same closed and matching endpoints
899 if isinstance(other_dtype, IntervalDtype):
900 if self.closed != other.closed:
901 return invalid_comparison(self, other, op)
902 elif not isinstance(other, Interval):
903 other = type(self)(other)
904
905 if op is operator.eq:
906 return (self._left == other.left) & (self._right == other.right)
907 elif op is operator.ne:
908 return (self._left != other.left) | (self._right != other.right)
909 elif op is operator.gt:
910 return (self._left > other.left) | (
911 (self._left == other.left) & (self._right > other.right)
912 )
913 elif op is operator.ge:
914 return (self == other) | (self > other)
915 elif op is operator.lt:
916 return (self._left < other.left) | (
917 (self._left == other.left) & (self._right < other.right)
918 )
919 else:
920 # operator.lt
921 return (self == other) | (self < other)
922
923 # non-interval/non-object dtype -> no matches
924 if not is_object_dtype(other_dtype):
925 return invalid_comparison(self, other, op)
926
927 # object dtype -> iteratively check for intervals
928 result = np.zeros(len(self), dtype=bool)
929 for i, obj in enumerate(other):
930 try:
931 result[i] = op(self[i], obj)
932 except TypeError:
933 if obj is NA:
934 # comparison with np.nan returns NA
935 # github.com/pandas-dev/pandas/pull/37124#discussion_r509095092
936 result = result.astype(object)
937 result[i] = NA
938 else:
939 raise
940 return result
941
942 @unpack_zerodim_and_defer("__eq__")
943 def __eq__(self, other):
944 return self._cmp_method(other, operator.eq)
945
946 @unpack_zerodim_and_defer("__ne__")
947 def __ne__(self, other):
948 return self._cmp_method(other, operator.ne)
949
950 @unpack_zerodim_and_defer("__gt__")
951 def __gt__(self, other):
952 return self._cmp_method(other, operator.gt)
953
954 @unpack_zerodim_and_defer("__ge__")
955 def __ge__(self, other):
956 return self._cmp_method(other, operator.ge)
957
958 @unpack_zerodim_and_defer("__lt__")
959 def __lt__(self, other):
960 return self._cmp_method(other, operator.lt)
961
962 @unpack_zerodim_and_defer("__le__")
963 def __le__(self, other):
964 return self._cmp_method(other, operator.le)
965
966 def argsort(
967 self,
968 *,
969 ascending: bool = True,
970 kind: SortKind = "quicksort",
971 na_position: str = "last",
972 **kwargs,
973 ) -> np.ndarray:
974 ascending = nv.validate_argsort_with_ascending(ascending, (), kwargs)
975
976 if ascending and kind == "quicksort" and na_position == "last":
977 # TODO: in an IntervalIndex we can reuse the cached
978 # IntervalTree.left_sorter
979 return np.lexsort((self.right, self.left))
980
981 # TODO: other cases we can use lexsort for? much more performant.
982 return super().argsort(
983 ascending=ascending, kind=kind, na_position=na_position, **kwargs
984 )
985
986 def min(self, *, axis: AxisInt | None = None, skipna: bool = True) -> IntervalOrNA:
987 nv.validate_minmax_axis(axis, self.ndim)
988
989 if not len(self):
990 return self._na_value
991
992 mask = self.isna()
993 if mask.any():
994 if not skipna:
995 return self._na_value
996 obj = self[~mask]
997 else:
998 obj = self
999
1000 indexer = obj.argsort()[0]
1001 return obj[indexer]
1002
1003 def max(self, *, axis: AxisInt | None = None, skipna: bool = True) -> IntervalOrNA:
1004 nv.validate_minmax_axis(axis, self.ndim)
1005
1006 if not len(self):
1007 return self._na_value
1008
1009 mask = self.isna()
1010 if mask.any():
1011 if not skipna:
1012 return self._na_value
1013 obj = self[~mask]
1014 else:
1015 obj = self
1016
1017 indexer = obj.argsort()[-1]
1018 return obj[indexer]
1019
1020 def fillna(self, value, limit: int | None = None, copy: bool = True) -> Self:
1021 """
1022 Fill NA/NaN values using the specified method.
1023
1024 Parameters
1025 ----------
1026 value : scalar, dict, Series
1027 If a scalar value is passed it is used to fill all missing values.
1028 Alternatively, a Series or dict can be used to fill in different
1029 values for each index. The value should not be a list. The
1030 value(s) passed should be either Interval objects or NA/NaN.
1031 limit : int, default None
1032 (Not implemented yet for IntervalArray)
1033 The maximum number of entries where NA values will be filled.
1034 copy : bool, default True
1035 Whether to make a copy of the data before filling. If False, then
1036 the original should be modified and no new memory should be allocated.
1037 For ExtensionArray subclasses that cannot do this, it is at the
1038 author's discretion whether to ignore "copy=False" or to raise.
1039
1040 Returns
1041 -------
1042 filled : IntervalArray with NA/NaN filled
1043 """
1044 if copy is False:
1045 raise NotImplementedError
1046 if limit is not None:
1047 raise ValueError("limit must be None")
1048
1049 value_left, value_right = self._validate_scalar(value)
1050
1051 left = self.left.fillna(value=value_left)
1052 right = self.right.fillna(value=value_right)
1053 return self._shallow_copy(left, right)
1054
1055 def astype(self, dtype, copy: bool = True):
1056 """
1057 Cast to an ExtensionArray or NumPy array with dtype 'dtype'.
1058
1059 Parameters
1060 ----------
1061 dtype : str or dtype
1062 Typecode or data-type to which the array is cast.
1063
1064 copy : bool, default True
1065 Whether to copy the data, even if not necessary. If False,
1066 a copy is made only if the old dtype does not match the
1067 new dtype.
1068
1069 Returns
1070 -------
1071 array : ExtensionArray or ndarray
1072 ExtensionArray or NumPy ndarray with 'dtype' for its dtype.
1073 """
1074 from pandas import Index
1075
1076 if dtype is not None:
1077 dtype = pandas_dtype(dtype)
1078
1079 if isinstance(dtype, IntervalDtype):
1080 if dtype == self.dtype:
1081 return self.copy() if copy else self
1082
1083 if is_float_dtype(self.dtype.subtype) and needs_i8_conversion(
1084 dtype.subtype
1085 ):
1086 # This is allowed on the Index.astype but we disallow it here
1087 msg = (
1088 f"Cannot convert {self.dtype} to {dtype}; subtypes are incompatible"
1089 )
1090 raise TypeError(msg)
1091
1092 # need to cast to different subtype
1093 try:
1094 # We need to use Index rules for astype to prevent casting
1095 # np.nan entries to int subtypes
1096 new_left = Index(self._left, copy=False).astype(dtype.subtype)
1097 new_right = Index(self._right, copy=False).astype(dtype.subtype)
1098 except IntCastingNaNError:
1099 # e.g test_subtype_integer
1100 raise
1101 except (TypeError, ValueError) as err:
1102 # e.g. test_subtype_integer_errors f8->u8 can be lossy
1103 # and raises ValueError
1104 msg = (
1105 f"Cannot convert {self.dtype} to {dtype}; subtypes are incompatible"
1106 )
1107 raise TypeError(msg) from err
1108 return self._shallow_copy(new_left, new_right)
1109 else:
1110 try:
1111 return super().astype(dtype, copy=copy)
1112 except (TypeError, ValueError) as err:
1113 msg = f"Cannot cast {type(self).__name__} to dtype {dtype}"
1114 raise TypeError(msg) from err
1115
1116 def equals(self, other) -> bool:
1117 if type(self) != type(other):
1118 return False
1119
1120 return bool(
1121 self.closed == other.closed
1122 and self.left.equals(other.left)
1123 and self.right.equals(other.right)
1124 )
1125
1126 @classmethod
1127 def _concat_same_type(cls, to_concat: Sequence[IntervalArray]) -> Self:
1128 """
1129 Concatenate multiple IntervalArray
1130
1131 Parameters
1132 ----------
1133 to_concat : sequence of IntervalArray
1134
1135 Returns
1136 -------
1137 IntervalArray
1138 """
1139 closed_set = {interval.closed for interval in to_concat}
1140 if len(closed_set) != 1:
1141 raise ValueError("Intervals must all be closed on the same side.")
1142 closed = closed_set.pop()
1143
1144 left: IntervalSide = np.concatenate([interval.left for interval in to_concat])
1145 right: IntervalSide = np.concatenate([interval.right for interval in to_concat])
1146
1147 left, right, dtype = cls._ensure_simple_new_inputs(left, right, closed=closed)
1148
1149 return cls._simple_new(left, right, dtype=dtype)
1150
1151 def copy(self) -> Self:
1152 """
1153 Return a copy of the array.
1154
1155 Returns
1156 -------
1157 IntervalArray
1158 """
1159 left = self._left.copy()
1160 right = self._right.copy()
1161 dtype = self.dtype
1162 return self._simple_new(left, right, dtype=dtype)
1163
1164 def isna(self) -> np.ndarray:
1165 return isna(self._left)
1166
1167 def shift(self, periods: int = 1, fill_value: object = None) -> IntervalArray:
1168 if not len(self) or periods == 0:
1169 return self.copy()
1170
1171 self._validate_scalar(fill_value)
1172
1173 # ExtensionArray.shift doesn't work for two reasons
1174 # 1. IntervalArray.dtype.na_value may not be correct for the dtype.
1175 # 2. IntervalArray._from_sequence only accepts NaN for missing values,
1176 # not other values like NaT
1177
1178 empty_len = min(abs(periods), len(self))
1179 if isna(fill_value):
1180 from pandas import Index
1181
1182 fill_value = Index(self._left, copy=False)._na_value
1183 empty = IntervalArray.from_breaks(
1184 [fill_value] * (empty_len + 1), closed=self.closed
1185 )
1186 else:
1187 empty = self._from_sequence([fill_value] * empty_len, dtype=self.dtype)
1188
1189 if periods > 0:
1190 a = empty
1191 b = self[:-periods]
1192 else:
1193 a = self[abs(periods) :]
1194 b = empty
1195 return self._concat_same_type([a, b])
1196
1197 def take(
1198 self,
1199 indices,
1200 *,
1201 allow_fill: bool = False,
1202 fill_value=None,
1203 axis=None,
1204 **kwargs,
1205 ) -> Self:
1206 """
1207 Take elements from the IntervalArray.
1208
1209 Parameters
1210 ----------
1211 indices : sequence of integers
1212 Indices to be taken.
1213
1214 allow_fill : bool, default False
1215 How to handle negative values in `indices`.
1216
1217 * False: negative values in `indices` indicate positional indices
1218 from the right (the default). This is similar to
1219 :func:`numpy.take`.
1220
1221 * True: negative values in `indices` indicate
1222 missing values. These values are set to `fill_value`. Any other
1223 other negative values raise a ``ValueError``.
1224
1225 fill_value : Interval or NA, optional
1226 Fill value to use for NA-indices when `allow_fill` is True.
1227 This may be ``None``, in which case the default NA value for
1228 the type, ``self.dtype.na_value``, is used.
1229
1230 For many ExtensionArrays, there will be two representations of
1231 `fill_value`: a user-facing "boxed" scalar, and a low-level
1232 physical NA value. `fill_value` should be the user-facing version,
1233 and the implementation should handle translating that to the
1234 physical version for processing the take if necessary.
1235
1236 axis : any, default None
1237 Present for compat with IntervalIndex; does nothing.
1238
1239 Returns
1240 -------
1241 IntervalArray
1242
1243 Raises
1244 ------
1245 IndexError
1246 When the indices are out of bounds for the array.
1247 ValueError
1248 When `indices` contains negative values other than ``-1``
1249 and `allow_fill` is True.
1250 """
1251 nv.validate_take((), kwargs)
1252
1253 fill_left = fill_right = fill_value
1254 if allow_fill:
1255 fill_left, fill_right = self._validate_scalar(fill_value)
1256
1257 left_take = take(
1258 self._left, indices, allow_fill=allow_fill, fill_value=fill_left
1259 )
1260 right_take = take(
1261 self._right, indices, allow_fill=allow_fill, fill_value=fill_right
1262 )
1263
1264 return self._shallow_copy(left_take, right_take)
1265
1266 def _validate_listlike(self, value):
1267 # list-like of intervals
1268 try:
1269 array = IntervalArray(value)
1270 self._check_closed_matches(array, name="value")
1271 value_left, value_right = array.left, array.right
1272 except TypeError as err:
1273 # wrong type: not interval or NA
1274 msg = f"'value' should be an interval type, got {type(value)} instead."
1275 raise TypeError(msg) from err
1276
1277 try:
1278 self.left._validate_fill_value(value_left)
1279 except (LossySetitemError, TypeError) as err:
1280 msg = (
1281 "'value' should be a compatible interval type, "
1282 f"got {type(value)} instead."
1283 )
1284 raise TypeError(msg) from err
1285
1286 return value_left, value_right
1287
1288 def _validate_scalar(self, value):
1289 if isinstance(value, Interval):
1290 self._check_closed_matches(value, name="value")
1291 left, right = value.left, value.right
1292 # TODO: check subdtype match like _validate_setitem_value?
1293 elif is_valid_na_for_dtype(value, self.left.dtype):
1294 # GH#18295
1295 left = right = self.left._na_value
1296 else:
1297 raise TypeError(
1298 "can only insert Interval objects and NA into an IntervalArray"
1299 )
1300 return left, right
1301
1302 def _validate_setitem_value(self, value):
1303 if is_valid_na_for_dtype(value, self.left.dtype):
1304 # na value: need special casing to set directly on numpy arrays
1305 value = self.left._na_value
1306 if is_integer_dtype(self.dtype.subtype):
1307 # can't set NaN on a numpy integer array
1308 # GH#45484 TypeError, not ValueError, matches what we get with
1309 # non-NA un-holdable value.
1310 raise TypeError("Cannot set float NaN to integer-backed IntervalArray")
1311 value_left, value_right = value, value
1312
1313 elif isinstance(value, Interval):
1314 # scalar interval
1315 self._check_closed_matches(value, name="value")
1316 value_left, value_right = value.left, value.right
1317 self.left._validate_fill_value(value_left)
1318 self.left._validate_fill_value(value_right)
1319
1320 else:
1321 return self._validate_listlike(value)
1322
1323 return value_left, value_right
1324
1325 # ---------------------------------------------------------------------
1326 # Rendering Methods
1327
1328 def _formatter(self, boxed: bool = False) -> Callable[[object], str]:
1329 # returning 'str' here causes us to render as e.g. "(0, 1]" instead of
1330 # "Interval(0, 1, closed='right')"
1331 return str
1332
1333 # ---------------------------------------------------------------------
1334 # Vectorized Interval Properties/Attributes
1335
1336 @property
1337 def left(self) -> Index:
1338 """
1339 Return the left endpoints of each Interval in the IntervalArray as an Index.
1340
1341 This property provides access to the left endpoints of the intervals
1342 contained within the IntervalArray. This can be useful for analyses where
1343 the starting point of each interval is of interest, such as in histogram
1344 creation, data aggregation, or any scenario requiring the identification
1345 of the beginning of defined ranges. This property returns a ``pandas.Index``
1346 object containing the midpoint for each interval.
1347
1348 See Also
1349 --------
1350 arrays.IntervalArray.right : Return the right endpoints of each Interval in
1351 the IntervalArray as an Index.
1352 arrays.IntervalArray.mid : Return the midpoint of each Interval in the
1353 IntervalArray as an Index.
1354 arrays.IntervalArray.contains : Check elementwise if the Intervals contain
1355 the value.
1356
1357 Examples
1358 --------
1359
1360 >>> interv_arr = pd.arrays.IntervalArray([pd.Interval(0, 1), pd.Interval(2, 5)])
1361 >>> interv_arr
1362 <IntervalArray>
1363 [(0, 1], (2, 5]]
1364 Length: 2, dtype: interval[int64, right]
1365 >>> interv_arr.left
1366 Index([0, 2], dtype='int64')
1367 """
1368 from pandas import Index
1369
1370 return Index(self._left, copy=False)
1371
1372 @property
1373 def right(self) -> Index:
1374 """
1375 Return the right endpoints of each Interval in the IntervalArray as an Index.
1376
1377 This property extracts the right endpoints from each interval contained within
1378 the IntervalArray. This can be helpful in use cases where you need to work
1379 with or compare only the upper bounds of intervals, such as when performing
1380 range-based filtering, determining interval overlaps, or visualizing the end
1381 boundaries of data segments.
1382
1383 See Also
1384 --------
1385 arrays.IntervalArray.left : Return the left endpoints of each Interval in
1386 the IntervalArray as an Index.
1387 arrays.IntervalArray.mid : Return the midpoint of each Interval in the
1388 IntervalArray as an Index.
1389 arrays.IntervalArray.contains : Check elementwise if the Intervals contain
1390 the value.
1391
1392 Examples
1393 --------
1394
1395 >>> interv_arr = pd.arrays.IntervalArray([pd.Interval(0, 1), pd.Interval(2, 5)])
1396 >>> interv_arr
1397 <IntervalArray>
1398 [(0, 1], (2, 5]]
1399 Length: 2, dtype: interval[int64, right]
1400 >>> interv_arr.right
1401 Index([1, 5], dtype='int64')
1402 """
1403 from pandas import Index
1404
1405 return Index(self._right, copy=False)
1406
1407 @property
1408 def length(self) -> Index:
1409 """
1410 Return an Index with entries denoting the length of each Interval.
1411
1412 The length of an interval is calculated as the difference between
1413 its `right` and `left` bounds. This property is particularly useful
1414 when working with intervals where the size of the interval is an important
1415 attribute, such as in time-series analysis or spatial data analysis.
1416
1417 See Also
1418 --------
1419 arrays.IntervalArray.left : Return the left endpoints of each Interval in
1420 the IntervalArray as an Index.
1421 arrays.IntervalArray.right : Return the right endpoints of each Interval in
1422 the IntervalArray as an Index.
1423 arrays.IntervalArray.mid : Return the midpoint of each Interval in the
1424 IntervalArray as an Index.
1425
1426 Examples
1427 --------
1428
1429 >>> interv_arr = pd.arrays.IntervalArray([pd.Interval(0, 1), pd.Interval(1, 5)])
1430 >>> interv_arr
1431 <IntervalArray>
1432 [(0, 1], (1, 5]]
1433 Length: 2, dtype: interval[int64, right]
1434 >>> interv_arr.length
1435 Index([1, 4], dtype='int64')
1436 """
1437 return self.right - self.left
1438
1439 @property
1440 def mid(self) -> Index:
1441 """
1442 Return the midpoint of each Interval in the IntervalArray as an Index.
1443
1444 The midpoint of an interval is calculated as the average of its
1445 ``left`` and ``right`` bounds. This property returns a ``pandas.Index`` object
1446 containing the midpoint for each interval.
1447
1448 See Also
1449 --------
1450 Interval.left : Return left bound for the interval.
1451 Interval.right : Return right bound for the interval.
1452 Interval.length : Return the length of each interval.
1453
1454 Examples
1455 --------
1456
1457 >>> interv_arr = pd.arrays.IntervalArray([pd.Interval(0, 1), pd.Interval(1, 5)])
1458 >>> interv_arr
1459 <IntervalArray>
1460 [(0, 1], (1, 5]]
1461 Length: 2, dtype: interval[int64, right]
1462 >>> interv_arr.mid
1463 Index([0.5, 3.0], dtype='float64')
1464 """
1465 try:
1466 return 0.5 * (self.left + self.right)
1467 except TypeError:
1468 # datetime safe version
1469 return self.left + 0.5 * self.length
1470
1471 _interval_shared_docs["overlaps"] = textwrap.dedent(
1472 """
1473 Check elementwise if an Interval overlaps the values in the %(klass)s.
1474
1475 Two intervals overlap if they share a common point, including closed
1476 endpoints. Intervals that only have an open endpoint in common do not
1477 overlap.
1478
1479 Parameters
1480 ----------
1481 other : Interval
1482 Interval to check against for an overlap.
1483
1484 Returns
1485 -------
1486 ndarray
1487 Boolean array positionally indicating where an overlap occurs.
1488
1489 See Also
1490 --------
1491 Interval.overlaps : Check whether two Interval objects overlap.
1492
1493 Examples
1494 --------
1495 %(examples)s
1496 >>> intervals.overlaps(pd.Interval(0.5, 1.5))
1497 array([ True, True, False])
1498
1499 Intervals that share closed endpoints overlap:
1500
1501 >>> intervals.overlaps(pd.Interval(1, 3, closed='left'))
1502 array([ True, True, True])
1503
1504 Intervals that only have an open endpoint in common do not overlap:
1505
1506 >>> intervals.overlaps(pd.Interval(1, 2, closed='right'))
1507 array([False, True, False])
1508 """
1509 )
1510
1511 def overlaps(self, other):
1512 """
1513 Check elementwise if an Interval overlaps the values in the IntervalArray.
1514
1515 Two intervals overlap if they share a common point, including closed
1516 endpoints. Intervals that only have an open endpoint in common do not
1517 overlap.
1518
1519 Parameters
1520 ----------
1521 other : IntervalArray
1522 Interval to check against for an overlap.
1523
1524 Returns
1525 -------
1526 ndarray
1527 Boolean array positionally indicating where an overlap occurs.
1528
1529 See Also
1530 --------
1531 Interval.overlaps : Check whether two Interval objects overlap.
1532
1533 Examples
1534 --------
1535 >>> data = [(0, 1), (1, 3), (2, 4)]
1536 >>> intervals = pd.arrays.IntervalArray.from_tuples(data)
1537 >>> intervals
1538 <IntervalArray>
1539 [(0, 1], (1, 3], (2, 4]]
1540 Length: 3, dtype: interval[int64, right]
1541
1542 >>> intervals.overlaps(pd.Interval(0.5, 1.5))
1543 array([ True, True, False])
1544
1545 Intervals that share closed endpoints overlap:
1546
1547 >>> intervals.overlaps(pd.Interval(1, 3, closed="left"))
1548 array([ True, True, True])
1549
1550 Intervals that only have an open endpoint in common do not overlap:
1551
1552 >>> intervals.overlaps(pd.Interval(1, 2, closed="right"))
1553 array([False, True, False])
1554 """
1555 if isinstance(other, (IntervalArray, ABCIntervalIndex)):
1556 raise NotImplementedError
1557 if not isinstance(other, Interval):
1558 msg = f"`other` must be Interval-like, got {type(other).__name__}"
1559 raise TypeError(msg)
1560
1561 # equality is okay if both endpoints are closed (overlap at a point)
1562 op1 = le if (self.closed_left and other.closed_right) else lt
1563 op2 = le if (other.closed_left and self.closed_right) else lt
1564
1565 # overlaps is equivalent negation of two interval being disjoint:
1566 # disjoint = (A.left > B.right) or (B.left > A.right)
1567 # (simplifying the negation allows this to be done in less operations)
1568 return op1(self.left, other.right) & op2(other.left, self.right)
1569
1570 # ---------------------------------------------------------------------
1571
1572 @property
1573 def closed(self) -> IntervalClosedType:
1574 """
1575 String describing the inclusive side the intervals.
1576
1577 Either ``left``, ``right``, ``both`` or ``neither``.
1578
1579 See Also
1580 --------
1581 IntervalArray.closed : Returns inclusive side of the IntervalArray.
1582 Interval.closed : Returns inclusive side of the Interval.
1583 IntervalIndex.closed : Returns inclusive side of the IntervalIndex.
1584
1585 Examples
1586 --------
1587
1588 For arrays:
1589
1590 >>> interv_arr = pd.arrays.IntervalArray([pd.Interval(0, 1), pd.Interval(1, 5)])
1591 >>> interv_arr
1592 <IntervalArray>
1593 [(0, 1], (1, 5]]
1594 Length: 2, dtype: interval[int64, right]
1595 >>> interv_arr.closed
1596 'right'
1597
1598 For Interval Index:
1599
1600 >>> interv_idx = pd.interval_range(start=0, end=2)
1601 >>> interv_idx
1602 IntervalIndex([(0, 1], (1, 2]], dtype='interval[int64, right]')
1603 >>> interv_idx.closed
1604 'right'
1605 """
1606 return self.dtype.closed
1607
1608 _interval_shared_docs["set_closed"] = textwrap.dedent(
1609 """
1610 Return an identical %(klass)s closed on the specified side.
1611
1612 Parameters
1613 ----------
1614 closed : {'left', 'right', 'both', 'neither'}
1615 Whether the intervals are closed on the left-side, right-side, both
1616 or neither.
1617
1618 Returns
1619 -------
1620 %(klass)s
1621
1622 %(examples)s\
1623 """
1624 )
1625
1626 def set_closed(self, closed: IntervalClosedType) -> Self:
1627 """
1628 Return an identical IntervalArray closed on the specified side.
1629
1630 Parameters
1631 ----------
1632 closed : {'left', 'right', 'both', 'neither'}
1633 Whether the intervals are closed on the left-side, right-side, both
1634 or neither.
1635
1636 Returns
1637 -------
1638 IntervalArray
1639 A new IntervalArray with the specified side closures.
1640
1641 See Also
1642 --------
1643 IntervalArray.closed : Returns inclusive side of the Interval.
1644 arrays.IntervalArray.closed : Returns inclusive side of the IntervalArray.
1645
1646 Examples
1647 --------
1648 >>> index = pd.arrays.IntervalArray.from_breaks(range(4))
1649 >>> index
1650 <IntervalArray>
1651 [(0, 1], (1, 2], (2, 3]]
1652 Length: 3, dtype: interval[int64, right]
1653 >>> index.set_closed("both")
1654 <IntervalArray>
1655 [[0, 1], [1, 2], [2, 3]]
1656 Length: 3, dtype: interval[int64, both]
1657 """
1658 if closed not in VALID_CLOSED:
1659 msg = f"invalid option for 'closed': {closed}"
1660 raise ValueError(msg)
1661
1662 left, right = self._left, self._right
1663 dtype = IntervalDtype(left.dtype, closed=closed)
1664 return self._simple_new(left, right, dtype=dtype)
1665
1666 _interval_shared_docs["is_non_overlapping_monotonic"] = """
1667 Return a boolean whether the %(klass)s is non-overlapping and monotonic.
1668
1669 Non-overlapping means (no Intervals share points), and monotonic means
1670 either monotonic increasing or monotonic decreasing.
1671
1672 Examples
1673 --------
1674 For arrays:
1675
1676 >>> interv_arr = pd.arrays.IntervalArray([pd.Interval(0, 1), pd.Interval(1, 5)])
1677 >>> interv_arr
1678 <IntervalArray>
1679 [(0, 1], (1, 5]]
1680 Length: 2, dtype: interval[int64, right]
1681 >>> interv_arr.is_non_overlapping_monotonic
1682 True
1683
1684 >>> interv_arr = pd.arrays.IntervalArray([pd.Interval(0, 1),
1685 ... pd.Interval(-1, 0.1)])
1686 >>> interv_arr
1687 <IntervalArray>
1688 [(0.0, 1.0], (-1.0, 0.1]]
1689 Length: 2, dtype: interval[float64, right]
1690 >>> interv_arr.is_non_overlapping_monotonic
1691 False
1692
1693 For Interval Index:
1694
1695 >>> interv_idx = pd.interval_range(start=0, end=2)
1696 >>> interv_idx
1697 IntervalIndex([(0, 1], (1, 2]], dtype='interval[int64, right]')
1698 >>> interv_idx.is_non_overlapping_monotonic
1699 True
1700
1701 >>> interv_idx = pd.interval_range(start=0, end=2, closed='both')
1702 >>> interv_idx
1703 IntervalIndex([[0, 1], [1, 2]], dtype='interval[int64, both]')
1704 >>> interv_idx.is_non_overlapping_monotonic
1705 False
1706 """
1707
1708 @property
1709 def is_non_overlapping_monotonic(self) -> bool:
1710 """
1711 Return a boolean whether the IntervalArray/IntervalIndex\
1712 is non-overlapping and monotonic.
1713
1714 Non-overlapping means (no Intervals share points), and monotonic means
1715 either monotonic increasing or monotonic decreasing.
1716
1717 See Also
1718 --------
1719 overlaps : Check if two IntervalIndex objects overlap.
1720
1721 Examples
1722 --------
1723 For arrays:
1724
1725 >>> interv_arr = pd.arrays.IntervalArray([pd.Interval(0, 1), pd.Interval(1, 5)])
1726 >>> interv_arr
1727 <IntervalArray>
1728 [(0, 1], (1, 5]]
1729 Length: 2, dtype: interval[int64, right]
1730 >>> interv_arr.is_non_overlapping_monotonic
1731 True
1732
1733 >>> interv_arr = pd.arrays.IntervalArray(
1734 ... [pd.Interval(0, 1), pd.Interval(-1, 0.1)]
1735 ... )
1736 >>> interv_arr
1737 <IntervalArray>
1738 [(0.0, 1.0], (-1.0, 0.1]]
1739 Length: 2, dtype: interval[float64, right]
1740 >>> interv_arr.is_non_overlapping_monotonic
1741 False
1742
1743 For Interval Index:
1744
1745 >>> interv_idx = pd.interval_range(start=0, end=2)
1746 >>> interv_idx
1747 IntervalIndex([(0, 1], (1, 2]], dtype='interval[int64, right]')
1748 >>> interv_idx.is_non_overlapping_monotonic
1749 True
1750
1751 >>> interv_idx = pd.interval_range(start=0, end=2, closed="both")
1752 >>> interv_idx
1753 IntervalIndex([[0, 1], [1, 2]], dtype='interval[int64, both]')
1754 >>> interv_idx.is_non_overlapping_monotonic
1755 False
1756 """
1757 # must be increasing (e.g., [0, 1), [1, 2), [2, 3), ... )
1758 # or decreasing (e.g., [-1, 0), [-2, -1), [-3, -2), ...)
1759 # we already require left <= right
1760
1761 # strict inequality for closed == 'both'; equality implies overlapping
1762 # at a point when both sides of intervals are included
1763 if self.closed == "both":
1764 return bool(
1765 (self._right[:-1] < self._left[1:]).all()
1766 or (self._left[:-1] > self._right[1:]).all()
1767 )
1768
1769 # non-strict inequality when closed != 'both'; at least one side is
1770 # not included in the intervals, so equality does not imply overlapping
1771 return bool(
1772 (self._right[:-1] <= self._left[1:]).all()
1773 or (self._left[:-1] >= self._right[1:]).all()
1774 )
1775
1776 # ---------------------------------------------------------------------
1777 # Conversion
1778
1779 def __array__(
1780 self, dtype: NpDtype | None = None, copy: bool | None = None
1781 ) -> np.ndarray:
1782 """
1783 Return the IntervalArray's data as a numpy array of Interval
1784 objects (with dtype='object')
1785 """
1786 if copy is False:
1787 raise ValueError(
1788 "Unable to avoid copy while creating an array as requested."
1789 )
1790
1791 left = self._left
1792 right = self._right
1793 mask = self.isna()
1794 closed = self.closed
1795
1796 result = np.empty(len(left), dtype=object)
1797 for i, left_value in enumerate(left):
1798 if mask[i]:
1799 result[i] = np.nan
1800 else:
1801 result[i] = Interval(left_value, right[i], closed)
1802 return result
1803
1804 def __arrow_array__(self, type=None):
1805 """
1806 Convert myself into a pyarrow Array.
1807 """
1808 import pyarrow
1809
1810 from pandas.core.arrays.arrow.extension_types import ArrowIntervalType
1811
1812 try:
1813 subtype = pyarrow.from_numpy_dtype(self.dtype.subtype)
1814 except TypeError as err:
1815 raise TypeError(
1816 f"Conversion to arrow with subtype '{self.dtype.subtype}' "
1817 "is not supported"
1818 ) from err
1819 interval_type = ArrowIntervalType(subtype, self.closed)
1820 storage_array = pyarrow.StructArray.from_arrays(
1821 [
1822 pyarrow.array(self._left, type=subtype, from_pandas=True),
1823 pyarrow.array(self._right, type=subtype, from_pandas=True),
1824 ],
1825 names=["left", "right"],
1826 )
1827 mask = self.isna()
1828 if mask.any():
1829 # if there are missing values, set validity bitmap also on the array level
1830 null_bitmap = pyarrow.array(~mask).buffers()[1]
1831 storage_array = pyarrow.StructArray.from_buffers(
1832 storage_array.type,
1833 len(storage_array),
1834 [null_bitmap],
1835 children=[storage_array.field(0), storage_array.field(1)],
1836 )
1837
1838 if type is not None:
1839 if type.equals(interval_type.storage_type):
1840 return storage_array
1841 elif isinstance(type, ArrowIntervalType):
1842 # ensure we have the same subtype and closed attributes
1843 if not type.equals(interval_type):
1844 raise TypeError(
1845 "Not supported to convert IntervalArray to type with "
1846 f"different 'subtype' ({self.dtype.subtype} vs {type.subtype}) "
1847 f"and 'closed' ({self.closed} vs {type.closed}) attributes"
1848 )
1849 else:
1850 raise TypeError(
1851 f"Not supported to convert IntervalArray to '{type}' type"
1852 )
1853
1854 return pyarrow.ExtensionArray.from_storage(interval_type, storage_array)
1855
1856 _interval_shared_docs["to_tuples"] = textwrap.dedent(
1857 """
1858 Return an %(return_type)s of tuples of the form (left, right).
1859
1860 Parameters
1861 ----------
1862 na_tuple : bool, default True
1863 If ``True``, return ``NA`` as a tuple ``(nan, nan)``. If ``False``,
1864 just return ``NA`` as ``nan``.
1865
1866 Returns
1867 -------
1868 tuples: %(return_type)s
1869 %(examples)s\
1870 """
1871 )
1872
1873 def to_tuples(self, na_tuple: bool = True) -> np.ndarray:
1874 """
1875 Return an ndarray (if self is IntervalArray) or Index \
1876 (if self is IntervalIndex) of tuples of the form (left, right).
1877
1878 Parameters
1879 ----------
1880 na_tuple : bool, default True
1881 If ``True``, return ``NA`` as a tuple ``(nan, nan)``. If ``False``,
1882 just return ``NA`` as ``nan``.
1883
1884 Returns
1885 -------
1886 ndarray or Index
1887 An ndarray of tuples representing the intervals
1888 if `self` is an IntervalArray.
1889 An Index of tuples representing the intervals
1890 if `self` is an IntervalIndex.
1891
1892 See Also
1893 --------
1894 IntervalArray.to_list : Convert IntervalArray to a list of tuples.
1895 IntervalArray.to_numpy : Convert IntervalArray to a numpy array.
1896 IntervalArray.unique : Find unique intervals in an IntervalArray.
1897
1898 Examples
1899 --------
1900 For :class:`pandas.IntervalArray`:
1901
1902 >>> idx = pd.arrays.IntervalArray.from_tuples([(0, 1), (1, 2)])
1903 >>> idx
1904 <IntervalArray>
1905 [(0, 1], (1, 2]]
1906 Length: 2, dtype: interval[int64, right]
1907 >>> idx.to_tuples()
1908 array([(np.int64(0), np.int64(1)), (np.int64(1), np.int64(2))],
1909 dtype=object)
1910
1911 For :class:`pandas.IntervalIndex`:
1912
1913 >>> idx = pd.interval_range(start=0, end=2)
1914 >>> idx
1915 IntervalIndex([(0, 1], (1, 2]], dtype='interval[int64, right]')
1916 >>> idx.to_tuples()
1917 Index([(0, 1), (1, 2)], dtype='object')
1918 """
1919 tuples = com.asarray_tuplesafe(zip(self._left, self._right, strict=True))
1920 if not na_tuple:
1921 # GH 18756
1922 tuples = np.where(~self.isna(), tuples, np.nan)
1923 return tuples
1924
1925 # ---------------------------------------------------------------------
1926
1927 def _putmask(self, mask: npt.NDArray[np.bool_], value) -> None:
1928 value_left, value_right = self._validate_setitem_value(value)
1929
1930 if isinstance(self._left, np.ndarray):
1931 np.putmask(self._left, mask, value_left)
1932 assert isinstance(self._right, np.ndarray)
1933 np.putmask(self._right, mask, value_right)
1934 else:
1935 self._left._putmask(mask, value_left)
1936 assert not isinstance(self._right, np.ndarray)
1937 self._right._putmask(mask, value_right)
1938
1939 def insert(self, loc: int, item: Interval) -> Self:
1940 """
1941 Return a new IntervalArray inserting new item at location. Follows
1942 Python numpy.insert semantics for negative values. Only Interval
1943 objects and NA can be inserted into an IntervalIndex
1944
1945 Parameters
1946 ----------
1947 loc : int
1948 item : Interval
1949
1950 Returns
1951 -------
1952 IntervalArray
1953 """
1954 left_insert, right_insert = self._validate_scalar(item)
1955
1956 new_left = self.left.insert(loc, left_insert)
1957 new_right = self.right.insert(loc, right_insert)
1958
1959 return self._shallow_copy(new_left, new_right)
1960
1961 def delete(self, loc) -> Self:
1962 new_left: np.ndarray | DatetimeArray | TimedeltaArray
1963 new_right: np.ndarray | DatetimeArray | TimedeltaArray
1964 if isinstance(self._left, np.ndarray):
1965 new_left = np.delete(self._left, loc)
1966 assert isinstance(self._right, np.ndarray)
1967 new_right = np.delete(self._right, loc)
1968 else:
1969 new_left = self._left.delete(loc)
1970 assert not isinstance(self._right, np.ndarray)
1971 new_right = self._right.delete(loc)
1972 return self._shallow_copy(left=new_left, right=new_right)
1973
1974 def repeat(
1975 self,
1976 repeats: int | Sequence[int],
1977 axis: AxisInt | None = None,
1978 ) -> Self:
1979 """
1980 Repeat elements of an IntervalArray.
1981
1982 Returns a new IntervalArray where each element of the current IntervalArray
1983 is repeated consecutively a given number of times.
1984
1985 Parameters
1986 ----------
1987 repeats : int or array of ints
1988 The number of repetitions for each element. This should be a
1989 non-negative integer. Repeating 0 times will return an empty
1990 IntervalArray.
1991 axis : None
1992 Must be ``None``. Has no effect but is accepted for compatibility
1993 with numpy.
1994
1995 Returns
1996 -------
1997 IntervalArray
1998 Newly created IntervalArray with repeated elements.
1999
2000 See Also
2001 --------
2002 Series.repeat : Equivalent function for Series.
2003 Index.repeat : Equivalent function for Index.
2004 numpy.repeat : Similar method for :class:`numpy.ndarray`.
2005 ExtensionArray.take : Take arbitrary positions.
2006
2007 Examples
2008 --------
2009 >>> cat = pd.Categorical(["a", "b", "c"])
2010 >>> cat
2011 ['a', 'b', 'c']
2012 Categories (3, str): ['a', 'b', 'c']
2013 >>> cat.repeat(2)
2014 ['a', 'a', 'b', 'b', 'c', 'c']
2015 Categories (3, str): ['a', 'b', 'c']
2016 >>> cat.repeat([1, 2, 3])
2017 ['a', 'b', 'b', 'c', 'c', 'c']
2018 Categories (3, str): ['a', 'b', 'c']
2019 """
2020 nv.validate_repeat((), {"axis": axis})
2021 left_repeat = self.left.repeat(repeats)
2022 right_repeat = self.right.repeat(repeats)
2023 return self._shallow_copy(left=left_repeat, right=right_repeat)
2024
2025 _interval_shared_docs["contains"] = textwrap.dedent(
2026 """
2027 Check elementwise if the Intervals contain the value.
2028
2029 Return a boolean mask whether the value is contained in the Intervals
2030 of the %(klass)s.
2031
2032 Parameters
2033 ----------
2034 other : scalar
2035 The value to check whether it is contained in the Intervals.
2036
2037 Returns
2038 -------
2039 boolean array
2040
2041 See Also
2042 --------
2043 Interval.contains : Check whether Interval object contains value.
2044 %(klass)s.overlaps : Check if an Interval overlaps the values in the
2045 %(klass)s.
2046
2047 Examples
2048 --------
2049 %(examples)s
2050 >>> intervals.contains(0.5)
2051 array([ True, False, False])
2052 """
2053 )
2054
2055 def contains(self, other):
2056 """
2057 Check elementwise if the Intervals contain the value.
2058
2059 Return a boolean mask whether the value is contained in the Intervals
2060 of the IntervalArray.
2061
2062 Parameters
2063 ----------
2064 other : scalar
2065 The value to check whether it is contained in the Intervals.
2066
2067 Returns
2068 -------
2069 boolean array
2070 A boolean mask whether the value is contained in the Intervals.
2071
2072 See Also
2073 --------
2074 Interval.contains : Check whether Interval object contains value.
2075 IntervalArray.overlaps : Check if an Interval overlaps the values in the
2076 IntervalArray.
2077
2078 Examples
2079 --------
2080 >>> intervals = pd.arrays.IntervalArray.from_tuples([(0, 1), (1, 3), (2, 4)])
2081 >>> intervals
2082 <IntervalArray>
2083 [(0, 1], (1, 3], (2, 4]]
2084 Length: 3, dtype: interval[int64, right]
2085
2086 >>> intervals.contains(0.5)
2087 array([ True, False, False])
2088 """
2089 if isinstance(other, Interval):
2090 raise NotImplementedError("contains not implemented for two intervals")
2091
2092 return (self._left < other if self.open_left else self._left <= other) & (
2093 other < self._right if self.open_right else other <= self._right
2094 )
2095
2096 def isin(self, values: ArrayLike) -> npt.NDArray[np.bool_]:
2097 if isinstance(values, IntervalArray):
2098 if self.closed != values.closed:
2099 # not comparable -> no overlap
2100 return np.zeros(self.shape, dtype=bool)
2101
2102 if self.dtype == values.dtype:
2103 left = self._combined
2104 right = values._combined
2105 return np.isin(left, right).ravel()
2106
2107 elif needs_i8_conversion(self.left.dtype) ^ needs_i8_conversion(
2108 values.left.dtype
2109 ):
2110 # not comparable -> no overlap
2111 return np.zeros(self.shape, dtype=bool)
2112
2113 return isin(self.astype(object), values.astype(object))
2114
2115 @property
2116 def _combined(self) -> IntervalSide:
2117 # error: Item "ExtensionArray" of "ExtensionArray | ndarray[Any, Any]"
2118 # has no attribute "reshape" [union-attr]
2119 left = self.left._values.reshape(-1, 1) # type: ignore[union-attr]
2120 right = self.right._values.reshape(-1, 1) # type: ignore[union-attr]
2121 # GH#38353 instead of casting to object, operating on a
2122 # complex128 ndarray is much more performant.
2123 if needs_i8_conversion(left.dtype):
2124 # error: Item "ndarray[Any, Any]" of "Any | ndarray[Any, Any]" has
2125 # no attribute "_concat_same_type"
2126 comb = left._concat_same_type( # type: ignore[union-attr]
2127 [left, right], axis=1
2128 )
2129 comb = comb.view("complex128")[:, 0]
2130 else:
2131 comb = np.asarray(left.ravel(), dtype="complex128")
2132 comb.imag = right.ravel()
2133 return comb
2134
2135 def _from_combined(self, combined: np.ndarray) -> IntervalArray:
2136 """
2137 Create a new IntervalArray with our dtype from a 1D complex128 ndarray.
2138 """
2139
2140 dtype = self._left.dtype
2141 if needs_i8_conversion(dtype):
2142 nc = combined.view("i8").reshape(-1, 2)
2143 assert isinstance(self._left, (DatetimeArray, TimedeltaArray))
2144 new_left: DatetimeArray | TimedeltaArray | np.ndarray = type(
2145 self._left
2146 )._from_sequence(nc[:, 0], dtype=dtype)
2147 assert isinstance(self._right, (DatetimeArray, TimedeltaArray))
2148 new_right: DatetimeArray | TimedeltaArray | np.ndarray = type(
2149 self._right
2150 )._from_sequence(nc[:, 1], dtype=dtype)
2151 else:
2152 assert isinstance(dtype, np.dtype)
2153 new_left = np.real(combined).astype(dtype).ravel()
2154 new_right = np.imag(combined).astype(dtype).ravel()
2155 return self._shallow_copy(left=new_left, right=new_right)
2156
2157 def unique(self) -> IntervalArray:
2158 nc = unique(self._combined)
2159 return self._from_combined(np.asarray(nc)[:, None])
2160
2161
2162def _maybe_convert_platform_interval(values) -> ArrayLike:
2163 """
2164 Try to do platform conversion, with special casing for IntervalArray.
2165 Wrapper around maybe_convert_platform that alters the default return
2166 dtype in certain cases to be compatible with IntervalArray. For example,
2167 empty lists return with integer dtype instead of object dtype, which is
2168 prohibited for IntervalArray.
2169
2170 Parameters
2171 ----------
2172 values : array-like
2173
2174 Returns
2175 -------
2176 array
2177 """
2178 if isinstance(values, (list, tuple)) and len(values) == 0:
2179 # GH 19016
2180 # empty lists/tuples get object dtype by default, but this is
2181 # prohibited for IntervalArray, so coerce to integer instead
2182 return np.array([], dtype=np.int64)
2183 elif not is_list_like(values) or isinstance(values, ABCDataFrame):
2184 # This will raise later, but we avoid passing to maybe_convert_platform
2185 return values
2186 elif isinstance(getattr(values, "dtype", None), CategoricalDtype):
2187 values = np.asarray(values)
2188 elif not hasattr(values, "dtype") and not isinstance(values, (list, tuple, range)):
2189 # TODO: should we just cast these to list?
2190 return values
2191 else:
2192 values = extract_array(values, extract_numpy=True)
2193
2194 if not hasattr(values, "dtype"):
2195 values = np.asarray(values)
2196 if values.dtype.kind in "iu" and values.dtype != np.int64:
2197 values = values.astype(np.int64)
2198 return values