import numbers from operator import ( le, lt, ) from cpython.datetime cimport ( PyDelta_Check, import_datetime, ) import_datetime() cimport cython from cpython.object cimport PyObject_RichCompare from cython cimport Py_ssize_t import numpy as np cimport numpy as cnp from numpy cimport ( NPY_QUICKSORT, PyArray_ArgSort, PyArray_Take, float64_t, int64_t, ndarray, uint64_t, ) cnp.import_array() from pandas._libs cimport util from pandas._libs.hashtable cimport Int64Vector from pandas._libs.tslibs.timedeltas cimport _Timedelta from pandas._libs.tslibs.timestamps cimport _Timestamp from pandas._libs.tslibs.timezones cimport tz_compare from pandas._libs.tslibs.util cimport ( is_float_object, is_integer_object, is_timedelta64_object, ) VALID_CLOSED = frozenset(["left", "right", "both", "neither"]) cdef class IntervalMixin: @property def closed_left(self): """ Check if the interval is closed on the left side. For the meaning of `closed` and `open` see :class:`~pandas.Interval`. Returns ------- bool True if the Interval is closed on the left-side. See Also -------- Interval.closed_right : Check if the interval is closed on the right side. Interval.open_left : Boolean inverse of closed_left. Examples -------- >>> iv = pd.Interval(0, 5, closed='left') >>> iv.closed_left True >>> iv = pd.Interval(0, 5, closed='right') >>> iv.closed_left False """ return self.closed in ("left", "both") @property def closed_right(self): """ Check if the interval is closed on the right side. For the meaning of `closed` and `open` see :class:`~pandas.Interval`. Returns ------- bool True if the Interval is closed on the left-side. See Also -------- Interval.closed_left : Check if the interval is closed on the left side. Interval.open_right : Boolean inverse of closed_right. Examples -------- >>> iv = pd.Interval(0, 5, closed='both') >>> iv.closed_right True >>> iv = pd.Interval(0, 5, closed='left') >>> iv.closed_right False """ return self.closed in ("right", "both") @property def open_left(self): """ Check if the interval is open on the left side. For the meaning of `closed` and `open` see :class:`~pandas.Interval`. Returns ------- bool True if the Interval is not closed on the left-side. See Also -------- Interval.open_right : Check if the interval is open on the right side. Interval.closed_left : Boolean inverse of open_left. Examples -------- >>> iv = pd.Interval(0, 5, closed='neither') >>> iv.open_left True >>> iv = pd.Interval(0, 5, closed='both') >>> iv.open_left False """ return not self.closed_left @property def open_right(self): """ Check if the interval is open on the right side. For the meaning of `closed` and `open` see :class:`~pandas.Interval`. Returns ------- bool True if the Interval is not closed on the left-side. See Also -------- Interval.open_left : Check if the interval is open on the left side. Interval.closed_right : Boolean inverse of open_right. Examples -------- >>> iv = pd.Interval(0, 5, closed='left') >>> iv.open_right True >>> iv = pd.Interval(0, 5) >>> iv.open_right False """ return not self.closed_right @property def mid(self): """ Return the midpoint of the Interval. Examples -------- >>> iv = pd.Interval(0, 5) >>> iv.mid 2.5 """ try: return 0.5 * (self.left + self.right) except TypeError: # datetime safe version return self.left + 0.5 * self.length @property def length(self): """ Return the length of the Interval. See Also -------- Interval.is_empty : Indicates if an interval contains no points. Examples -------- >>> interval = pd.Interval(left=1, right=2, closed='left') >>> interval Interval(1, 2, closed='left') >>> interval.length 1 """ return self.right - self.left @property def is_empty(self): """ Indicates if an interval is empty, meaning it contains no points. Returns ------- bool or ndarray A boolean indicating if a scalar :class:`Interval` is empty, or a boolean ``ndarray`` positionally indicating if an ``Interval`` in an :class:`~arrays.IntervalArray` or :class:`IntervalIndex` is empty. See Also -------- Interval.length : Return the length of the Interval. Examples -------- An :class:`Interval` that contains points is not empty: >>> pd.Interval(0, 1, closed='right').is_empty False An ``Interval`` that does not contain any points is empty: >>> pd.Interval(0, 0, closed='right').is_empty True >>> pd.Interval(0, 0, closed='left').is_empty True >>> pd.Interval(0, 0, closed='neither').is_empty True An ``Interval`` that contains a single point is not empty: >>> pd.Interval(0, 0, closed='both').is_empty False An :class:`~arrays.IntervalArray` or :class:`IntervalIndex` returns a boolean ``ndarray`` positionally indicating if an ``Interval`` is empty: >>> ivs = [pd.Interval(0, 0, closed='neither'), ... pd.Interval(1, 2, closed='neither')] >>> pd.arrays.IntervalArray(ivs).is_empty array([ True, False]) Missing values are not considered empty: >>> ivs = [pd.Interval(0, 0, closed='neither'), np.nan] >>> pd.IntervalIndex(ivs).is_empty array([ True, False]) """ return (self.right == self.left) & (self.closed != "both") def _check_closed_matches(self, other, name="other"): """ Check if the closed attribute of `other` matches. Note that 'left' and 'right' are considered different from 'both'. Parameters ---------- other : Interval, IntervalIndex, IntervalArray name : str Name to use for 'other' in the error message. Raises ------ ValueError When `other` is not closed exactly the same as self. """ if self.closed != other.closed: raise ValueError(f"'{name}.closed' is {repr(other.closed)}, " f"expected {repr(self.closed)}.") cdef bint _interval_like(other): return (hasattr(other, "left") and hasattr(other, "right") and hasattr(other, "closed")) cdef class Interval(IntervalMixin): """ Immutable object implementing an Interval, a bounded slice-like interval. Parameters ---------- left : orderable scalar Left bound for the interval. right : orderable scalar Right bound for the interval. closed : {'right', 'left', 'both', 'neither'}, default 'right' Whether the interval is closed on the left-side, right-side, both or neither. See the Notes for more detailed explanation. See Also -------- IntervalIndex : An Index of Interval objects that are all closed on the same side. cut : Convert continuous data into discrete bins (Categorical of Interval objects). qcut : Convert continuous data into bins (Categorical of Interval objects) based on quantiles. Period : Represents a period of time. Notes ----- The parameters `left` and `right` must be from the same type, you must be able to compare them and they must satisfy ``left <= right``. A closed interval (in mathematics denoted by square brackets) contains its endpoints, i.e. the closed interval ``[0, 5]`` is characterized by the conditions ``0 <= x <= 5``. This is what ``closed='both'`` stands for. An open interval (in mathematics denoted by parentheses) does not contain its endpoints, i.e. the open interval ``(0, 5)`` is characterized by the conditions ``0 < x < 5``. This is what ``closed='neither'`` stands for. Intervals can also be half-open or half-closed, i.e. ``[0, 5)`` is described by ``0 <= x < 5`` (``closed='left'``) and ``(0, 5]`` is described by ``0 < x <= 5`` (``closed='right'``). Examples -------- It is possible to build Intervals of different types, like numeric ones: >>> iv = pd.Interval(left=0, right=5) >>> iv Interval(0, 5, closed='right') You can check if an element belongs to it, or if it contains another interval: >>> 2.5 in iv True >>> pd.Interval(left=2, right=5, closed='both') in iv True You can test the bounds (``closed='right'``, so ``0 < x <= 5``): >>> 0 in iv False >>> 5 in iv True >>> 0.0001 in iv True Calculate its length >>> iv.length 5 You can operate with `+` and `*` over an Interval and the operation is applied to each of its bounds, so the result depends on the type of the bound elements >>> shifted_iv = iv + 3 >>> shifted_iv Interval(3, 8, closed='right') >>> extended_iv = iv * 10.0 >>> extended_iv Interval(0.0, 50.0, closed='right') To create a time interval you can use Timestamps as the bounds >>> year_2017 = pd.Interval(pd.Timestamp('2017-01-01 00:00:00'), ... pd.Timestamp('2018-01-01 00:00:00'), ... closed='left') >>> pd.Timestamp('2017-01-01 00:00') in year_2017 True >>> year_2017.length Timedelta('365 days 00:00:00') """ _typ = "interval" __array_priority__ = 1000 cdef readonly object left """ Left bound for the interval. Examples -------- >>> interval = pd.Interval(left=1, right=2, closed='left') >>> interval Interval(1, 2, closed='left') >>> interval.left 1 """ cdef readonly object right """ Right bound for the interval. Examples -------- >>> interval = pd.Interval(left=1, right=2, closed='left') >>> interval Interval(1, 2, closed='left') >>> interval.right 2 """ cdef readonly str closed """ String describing the inclusive side the intervals. Either ``left``, ``right``, ``both`` or ``neither``. Examples -------- >>> interval = pd.Interval(left=1, right=2, closed='left') >>> interval Interval(1, 2, closed='left') >>> interval.closed 'left' """ def __init__(self, left, right, str closed="right"): # note: it is faster to just do these checks than to use a special # constructor (__cinit__/__new__) to avoid them self._validate_endpoint(left) self._validate_endpoint(right) if closed not in VALID_CLOSED: raise ValueError(f"invalid option for 'closed': {closed}") if not left <= right: raise ValueError("left side of interval must be <= right side") if (isinstance(left, _Timestamp) and not tz_compare(left.tzinfo, right.tzinfo)): # GH 18538 raise ValueError("left and right must have the same time zone, got " f"{repr(left.tzinfo)}' and {repr(right.tzinfo)}") self.left = left self.right = right self.closed = closed def _validate_endpoint(self, endpoint): # GH 23013 if not (is_integer_object(endpoint) or is_float_object(endpoint) or isinstance(endpoint, (_Timestamp, _Timedelta))): raise ValueError("Only numeric, Timestamp and Timedelta endpoints " "are allowed when constructing an Interval.") def __hash__(self): return hash((self.left, self.right, self.closed)) def __contains__(self, key) -> bool: if _interval_like(key): key_closed_left = key.closed in ("left", "both") key_closed_right = key.closed in ("right", "both") if self.open_left and key_closed_left: left_contained = self.left < key.left else: left_contained = self.left <= key.left if self.open_right and key_closed_right: right_contained = key.right < self.right else: right_contained = key.right <= self.right return left_contained and right_contained return ((self.left < key if self.open_left else self.left <= key) and (key < self.right if self.open_right else key <= self.right)) def __richcmp__(self, other, op: int): if isinstance(other, Interval): self_tuple = (self.left, self.right, self.closed) other_tuple = (other.left, other.right, other.closed) return PyObject_RichCompare(self_tuple, other_tuple, op) elif util.is_array(other): return np.array( [PyObject_RichCompare(self, x, op) for x in other], dtype=bool, ) return NotImplemented def __reduce__(self): args = (self.left, self.right, self.closed) return (type(self), args) def _repr_base(self): left = self.left right = self.right # TODO: need more general formatting methodology here if isinstance(left, _Timestamp) and isinstance(right, _Timestamp): left = left._short_repr right = right._short_repr return left, right def __repr__(self) -> str: left, right = self._repr_base() disp = str if isinstance(left, np.generic) else repr name = type(self).__name__ repr_str = f"{name}({disp(left)}, {disp(right)}, closed={repr(self.closed)})" return repr_str def __str__(self) -> str: left, right = self._repr_base() start_symbol = "[" if self.closed_left else "(" end_symbol = "]" if self.closed_right else ")" return f"{start_symbol}{left}, {right}{end_symbol}" def __add__(self, y): if ( isinstance(y, numbers.Number) or PyDelta_Check(y) or is_timedelta64_object(y) ): return Interval(self.left + y, self.right + y, closed=self.closed) elif ( # __radd__ pattern # TODO(cython3): remove this isinstance(y, Interval) and ( isinstance(self, numbers.Number) or PyDelta_Check(self) or is_timedelta64_object(self) ) ): return Interval(y.left + self, y.right + self, closed=y.closed) return NotImplemented def __radd__(self, other): if ( isinstance(other, numbers.Number) or PyDelta_Check(other) or is_timedelta64_object(other) ): return Interval(self.left + other, self.right + other, closed=self.closed) return NotImplemented def __sub__(self, y): if ( isinstance(y, numbers.Number) or PyDelta_Check(y) or is_timedelta64_object(y) ): return Interval(self.left - y, self.right - y, closed=self.closed) return NotImplemented def __mul__(self, y): if isinstance(y, numbers.Number): return Interval(self.left * y, self.right * y, closed=self.closed) elif isinstance(y, Interval) and isinstance(self, numbers.Number): # __radd__ semantics # TODO(cython3): remove this return Interval(y.left * self, y.right * self, closed=y.closed) return NotImplemented def __rmul__(self, other): if isinstance(other, numbers.Number): return Interval(self.left * other, self.right * other, closed=self.closed) return NotImplemented def __truediv__(self, y): if isinstance(y, numbers.Number): return Interval(self.left / y, self.right / y, closed=self.closed) return NotImplemented def __floordiv__(self, y): if isinstance(y, numbers.Number): return Interval( self.left // y, self.right // y, closed=self.closed) return NotImplemented def overlaps(self, other): """ Check whether two Interval objects overlap. Two intervals overlap if they share a common point, including closed endpoints. Intervals that only have an open endpoint in common do not overlap. Parameters ---------- other : Interval Interval to check against for an overlap. Returns ------- bool True if the two intervals overlap. See Also -------- IntervalArray.overlaps : The corresponding method for IntervalArray. IntervalIndex.overlaps : The corresponding method for IntervalIndex. Examples -------- >>> i1 = pd.Interval(0, 2) >>> i2 = pd.Interval(1, 3) >>> i1.overlaps(i2) True >>> i3 = pd.Interval(4, 5) >>> i1.overlaps(i3) False Intervals that share closed endpoints overlap: >>> i4 = pd.Interval(0, 1, closed='both') >>> i5 = pd.Interval(1, 2, closed='both') >>> i4.overlaps(i5) True Intervals that only have an open endpoint in common do not overlap: >>> i6 = pd.Interval(1, 2, closed='neither') >>> i4.overlaps(i6) False """ if not isinstance(other, Interval): raise TypeError("`other` must be an Interval, " f"got {type(other).__name__}") # equality is okay if both endpoints are closed (overlap at a point) op1 = le if (self.closed_left and other.closed_right) else lt op2 = le if (other.closed_left and self.closed_right) else lt # overlaps is equivalent negation of two interval being disjoint: # disjoint = (A.left > B.right) or (B.left > A.right) # (simplifying the negation allows this to be done in less operations) return op1(self.left, other.right) and op2(other.left, self.right) @cython.wraparound(False) @cython.boundscheck(False) def intervals_to_interval_bounds(ndarray intervals, bint validate_closed=True): """ Parameters ---------- intervals : ndarray Object array of Intervals / nulls. validate_closed: bool, default True Boolean indicating if all intervals must be closed on the same side. Mismatching closed will raise if True, else return None for closed. Returns ------- tuple of left : ndarray right : ndarray closed: IntervalClosedType """ cdef: object closed = None, interval Py_ssize_t i, n = len(intervals) ndarray left, right bint seen_closed = False left = np.empty(n, dtype=intervals.dtype) right = np.empty(n, dtype=intervals.dtype) for i in range(n): interval = intervals[i] if interval is None or util.is_nan(interval): left[i] = np.nan right[i] = np.nan continue if not isinstance(interval, Interval): raise TypeError(f"type {type(interval)} with value " f"{interval} is not an interval") left[i] = interval.left right[i] = interval.right if not seen_closed: seen_closed = True closed = interval.closed elif closed != interval.closed: closed = None if validate_closed: raise ValueError("intervals must all be closed on the same side") return left, right, closed include "intervaltree.pxi"