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