1from __future__ import annotations
2
3from datetime import (
4 datetime,
5 timedelta,
6)
7from typing import (
8 TYPE_CHECKING,
9 Self,
10)
11
12import numpy as np
13
14from pandas._libs import index as libindex
15from pandas._libs.tslibs import (
16 BaseOffset,
17 Day,
18 NaT,
19 Period,
20 Resolution,
21 Tick,
22)
23from pandas._libs.tslibs.dtypes import OFFSET_TO_PERIOD_FREQSTR
24from pandas.util._decorators import (
25 cache_readonly,
26 doc,
27 set_module,
28)
29
30from pandas.core.dtypes.common import is_integer
31from pandas.core.dtypes.dtypes import PeriodDtype
32from pandas.core.dtypes.generic import ABCSeries
33from pandas.core.dtypes.missing import is_valid_na_for_dtype
34
35from pandas.core.arrays.period import (
36 PeriodArray,
37 period_array,
38 raise_on_incompatible,
39 validate_dtype_freq,
40)
41import pandas.core.common as com
42import pandas.core.indexes.base as ibase
43from pandas.core.indexes.base import maybe_extract_name
44from pandas.core.indexes.datetimelike import DatetimeIndexOpsMixin
45from pandas.core.indexes.datetimes import (
46 DatetimeIndex,
47 Index,
48)
49from pandas.core.indexes.extension import inherit_names
50
51if TYPE_CHECKING:
52 from collections.abc import Hashable
53
54 from pandas._typing import (
55 Dtype,
56 DtypeObj,
57 npt,
58 )
59
60
61_index_doc_kwargs = dict(ibase._index_doc_kwargs)
62_index_doc_kwargs.update({"target_klass": "PeriodIndex or list of Periods"})
63_shared_doc_kwargs = {
64 "klass": "PeriodArray",
65}
66
67# --- Period index sketch
68
69
70def _new_PeriodIndex(cls, **d):
71 # GH13277 for unpickling
72 values = d.pop("data")
73 if values.dtype == "int64":
74 freq = d.pop("freq", None)
75 dtype = PeriodDtype(freq)
76 values = PeriodArray(values, dtype=dtype)
77 return cls._simple_new(values, **d)
78 else:
79 return cls(values, **d)
80
81
82@inherit_names(
83 ["strftime", "start_time", "end_time", *PeriodArray._field_ops],
84 PeriodArray,
85 wrap=True,
86)
87@inherit_names(["is_leap_year"], PeriodArray)
88@set_module("pandas")
89class PeriodIndex(DatetimeIndexOpsMixin):
90 """
91 Immutable ndarray holding ordinal values indicating regular periods in time.
92
93 Index keys are boxed to Period objects which carries the metadata (eg,
94 frequency information).
95
96 Parameters
97 ----------
98 data : array-like (1d int np.ndarray or PeriodArray), optional
99 Optional period-like data to construct index with.
100 freq : str or period object, optional
101 One of pandas period strings or corresponding objects.
102 dtype : str or PeriodDtype, default None
103 A dtype from which to extract a freq.
104 copy : bool, default None
105 Whether to copy input data, only relevant for array, Series, and Index
106 inputs (for other input, e.g. a list, a new array is created anyway).
107 Defaults to True for array input and False for Index/Series.
108 Set to False to avoid copying array input at your own risk (if you
109 know the input data won't be modified elsewhere).
110 Set to True to force copying Series/Index input up front.
111 name : str, default None
112 Name of the resulting PeriodIndex.
113
114 Attributes
115 ----------
116 day
117 dayofweek
118 day_of_week
119 dayofyear
120 day_of_year
121 days_in_month
122 daysinmonth
123 end_time
124 freq
125 freqstr
126 hour
127 is_leap_year
128 minute
129 month
130 quarter
131 qyear
132 second
133 start_time
134 week
135 weekday
136 weekofyear
137 year
138
139 Methods
140 -------
141 asfreq
142 strftime
143 to_timestamp
144 from_fields
145 from_ordinals
146
147 Raises
148 ------
149 ValueError
150 Passing the parameter data as a list without specifying either freq or
151 dtype will raise a ValueError: "freq not specified and cannot be inferred"
152
153 See Also
154 --------
155 Index : The base pandas Index type.
156 Period : Represents a period of time.
157 DatetimeIndex : Index with datetime64 data.
158 TimedeltaIndex : Index of timedelta64 data.
159 period_range : Create a fixed-frequency PeriodIndex.
160
161 Examples
162 --------
163 >>> idx = pd.PeriodIndex(data=["2000Q1", "2002Q3"], freq="Q")
164 >>> idx
165 PeriodIndex(['2000Q1', '2002Q3'], dtype='period[Q-DEC]')
166 """
167
168 _typ = "periodindex"
169
170 _data: PeriodArray
171 freq: BaseOffset
172 dtype: PeriodDtype
173
174 _data_cls = PeriodArray
175 _supports_partial_string_indexing = True
176
177 @property
178 def _engine_type(self) -> type[libindex.PeriodEngine]:
179 return libindex.PeriodEngine
180
181 @cache_readonly
182 def _resolution_obj(self) -> Resolution:
183 # for compat with DatetimeIndex
184 return self.dtype._resolution_obj
185
186 # --------------------------------------------------------------------
187 # methods that dispatch to array and wrap result in Index
188 # These are defined here instead of via inherit_names for mypy
189
190 @doc(
191 PeriodArray.asfreq,
192 other="arrays.PeriodArray",
193 other_name="PeriodArray",
194 **_shared_doc_kwargs,
195 )
196 def asfreq(self, freq=None, how: str = "E") -> Self:
197 arr = self._data.asfreq(freq, how)
198 return type(self)._simple_new(arr, name=self.name)
199
200 @doc(PeriodArray.to_timestamp)
201 def to_timestamp(self, freq=None, how: str = "start") -> DatetimeIndex:
202 arr = self._data.to_timestamp(freq, how)
203 return DatetimeIndex._simple_new(arr, name=self.name)
204
205 @property
206 @doc(PeriodArray.hour.fget)
207 def hour(self) -> Index:
208 return Index(self._data.hour, name=self.name, copy=False)
209
210 @property
211 @doc(PeriodArray.minute.fget)
212 def minute(self) -> Index:
213 return Index(self._data.minute, name=self.name, copy=False)
214
215 @property
216 @doc(PeriodArray.second.fget)
217 def second(self) -> Index:
218 return Index(self._data.second, name=self.name, copy=False)
219
220 # ------------------------------------------------------------------------
221 # Index Constructors
222
223 def __new__(
224 cls,
225 data=None,
226 freq=None,
227 dtype: Dtype | None = None,
228 copy: bool | None = None,
229 name: Hashable | None = None,
230 ) -> Self:
231 refs = None
232 if not copy and isinstance(data, (Index, ABCSeries)):
233 refs = data._references
234
235 name = maybe_extract_name(name, data, cls)
236
237 freq = validate_dtype_freq(dtype, freq)
238
239 # GH#63388
240 data, copy = cls._maybe_copy_array_input(data, copy, dtype)
241
242 # PeriodIndex allow PeriodIndex(period_index, freq=different)
243 # Let's not encourage that kind of behavior in PeriodArray.
244
245 if freq and isinstance(data, cls) and data.freq != freq:
246 # TODO: We can do some of these with no-copy / coercion?
247 # e.g. D -> 2D seems to be OK
248 data = data.asfreq(freq)
249
250 # don't pass copy here, since we copy later.
251 data = period_array(data=data, freq=freq)
252
253 if copy:
254 data = data.copy()
255
256 return cls._simple_new(data, name=name, refs=refs)
257
258 @classmethod
259 def from_fields(
260 cls,
261 *,
262 year=None,
263 quarter=None,
264 month=None,
265 day=None,
266 hour=None,
267 minute=None,
268 second=None,
269 freq=None,
270 ) -> Self:
271 """
272 Construct a PeriodIndex from fields (year, month, day, etc.).
273
274 Parameters
275 ----------
276 year : int, array, or Series, default None
277 Year for the PeriodIndex.
278 quarter : int, array, or Series, default None
279 Quarter for the PeriodIndex.
280 month : int, array, or Series, default None
281 Month for the PeriodIndex.
282 day : int, array, or Series, default None
283 Day for the PeriodIndex.
284 hour : int, array, or Series, default None
285 Hour for the PeriodIndex.
286 minute : int, array, or Series, default None
287 Minute for the PeriodIndex.
288 second : int, array, or Series, default None
289 Second for the PeriodIndex.
290 freq : str or period object, optional
291 One of pandas period strings or corresponding objects.
292
293 Returns
294 -------
295 PeriodIndex
296
297 See Also
298 --------
299 PeriodIndex.from_ordinals : Construct a PeriodIndex from ordinals.
300 PeriodIndex.to_timestamp : Cast to DatetimeArray/Index.
301
302 Examples
303 --------
304 >>> idx = pd.PeriodIndex.from_fields(year=[2000, 2002], quarter=[1, 3])
305 >>> idx
306 PeriodIndex(['2000Q1', '2002Q3'], dtype='period[Q-DEC]')
307 """
308 fields = {
309 "year": year,
310 "quarter": quarter,
311 "month": month,
312 "day": day,
313 "hour": hour,
314 "minute": minute,
315 "second": second,
316 }
317 fields = {key: value for key, value in fields.items() if value is not None}
318 arr = PeriodArray._from_fields(fields=fields, freq=freq)
319 return cls._simple_new(arr)
320
321 @classmethod
322 def from_ordinals(cls, ordinals, *, freq, name=None) -> Self:
323 """
324 Construct a PeriodIndex from ordinals.
325
326 Parameters
327 ----------
328 ordinals : array-like of int
329 The period offsets from the proleptic Gregorian epoch.
330 freq : str or period object
331 One of pandas period strings or corresponding objects.
332 name : str, default None
333 Name of the resulting PeriodIndex.
334
335 Returns
336 -------
337 PeriodIndex
338
339 See Also
340 --------
341 PeriodIndex.from_fields : Construct a PeriodIndex from fields
342 (year, month, day, etc.).
343 PeriodIndex.to_timestamp : Cast to DatetimeArray/Index.
344
345 Examples
346 --------
347 >>> idx = pd.PeriodIndex.from_ordinals([-1, 0, 1], freq="Q")
348 >>> idx
349 PeriodIndex(['1969Q4', '1970Q1', '1970Q2'], dtype='period[Q-DEC]')
350 """
351 ordinals = np.asarray(ordinals, dtype=np.int64)
352 dtype = PeriodDtype(freq)
353 data = PeriodArray._simple_new(ordinals, dtype=dtype)
354 return cls._simple_new(data, name=name)
355
356 # ------------------------------------------------------------------------
357 # Data
358
359 @property
360 def values(self) -> npt.NDArray[np.object_]:
361 return np.asarray(self, dtype=object)
362
363 def _maybe_convert_timedelta(self, other) -> int | npt.NDArray[np.int64]:
364 """
365 Convert timedelta-like input to an integer multiple of self.freq
366
367 Parameters
368 ----------
369 other : timedelta, np.timedelta64, DateOffset, int, np.ndarray
370
371 Returns
372 -------
373 converted : int, np.ndarray[int64]
374
375 Raises
376 ------
377 IncompatibleFrequency : if the input cannot be written as a multiple
378 of self.freq. Note IncompatibleFrequency subclasses ValueError.
379 """
380 if isinstance(other, (timedelta, np.timedelta64, Tick, np.ndarray)):
381 if isinstance(self.freq, (Tick, Day)):
382 # _check_timedeltalike_freq_compat will raise if incompatible
383 delta = self._data._check_timedeltalike_freq_compat(other)
384 return delta
385 elif isinstance(other, BaseOffset):
386 if other.base == self.freq.base:
387 return other.n
388
389 raise raise_on_incompatible(self, other)
390 elif is_integer(other):
391 assert isinstance(other, int)
392 return other
393
394 # raise when input doesn't have freq
395 raise raise_on_incompatible(self, None)
396
397 def _is_comparable_dtype(self, dtype: DtypeObj) -> bool:
398 """
399 Can we compare values of the given dtype to our own?
400 """
401 return self.dtype == dtype
402
403 # ------------------------------------------------------------------------
404 # Index Methods
405
406 def asof_locs(self, where: Index, mask: npt.NDArray[np.bool_]) -> np.ndarray:
407 """
408 where : array of timestamps
409 mask : np.ndarray[bool]
410 Array of booleans where data is not NA.
411 """
412 if isinstance(where, DatetimeIndex):
413 where = PeriodIndex(where._values, freq=self.freq, copy=False)
414 elif not isinstance(where, PeriodIndex):
415 raise TypeError("asof_locs `where` must be DatetimeIndex or PeriodIndex")
416
417 return super().asof_locs(where, mask)
418
419 @property
420 def is_full(self) -> bool:
421 """
422 Returns True if this PeriodIndex is range-like in that all Periods
423 between start and end are present, in order.
424 """
425 if len(self) == 0:
426 return True
427 if not self.is_monotonic_increasing:
428 raise ValueError("Index is not monotonic")
429 values = self.asi8
430 return bool(((values[1:] - values[:-1]) < 2).all())
431
432 @property
433 def inferred_type(self) -> str:
434 # b/c data is represented as ints make sure we can't have ambiguous
435 # indexing
436 return "period"
437
438 # ------------------------------------------------------------------------
439 # Indexing Methods
440
441 def _convert_tolerance(self, tolerance, target):
442 # Returned tolerance must be in dtype/units so that
443 # `|self._get_engine_target() - target._engine_target()| <= tolerance`
444 # is meaningful. Since PeriodIndex returns int64 for engine_target,
445 # we may need to convert timedelta64 tolerance to int64.
446 tolerance = super()._convert_tolerance(tolerance, target)
447
448 if self.dtype == target.dtype:
449 # convert tolerance to i8
450 tolerance = self._maybe_convert_timedelta(tolerance)
451
452 return tolerance
453
454 def get_loc(self, key):
455 """
456 Get integer location for requested label.
457
458 Parameters
459 ----------
460 key : Period, NaT, str, or datetime
461 String or datetime key must be parsable as Period.
462
463 Returns
464 -------
465 loc : int or ndarray[int64]
466
467 Raises
468 ------
469 KeyError
470 Key is not present in the index.
471 TypeError
472 If key is listlike or otherwise not hashable.
473 """
474 orig_key = key
475
476 self._check_indexing_error(key)
477
478 if is_valid_na_for_dtype(key, self.dtype):
479 key = NaT
480
481 elif isinstance(key, str):
482 try:
483 parsed, reso = self._parse_with_reso(key)
484 except ValueError as err:
485 # A string with invalid format
486 raise KeyError(f"Cannot interpret '{key}' as period") from err
487
488 if self._can_partial_date_slice(reso):
489 try:
490 return self._partial_date_slice(reso, parsed)
491 except KeyError as err:
492 raise KeyError(key) from err
493
494 if reso == self._resolution_obj:
495 # the reso < self._resolution_obj case goes
496 # through _get_string_slice
497 key = self._cast_partial_indexing_scalar(parsed)
498 else:
499 raise KeyError(key)
500
501 elif isinstance(key, Period):
502 self._disallow_mismatched_indexing(key)
503
504 elif isinstance(key, datetime):
505 key = self._cast_partial_indexing_scalar(key)
506
507 else:
508 # in particular integer, which Period constructor would cast to string
509 raise KeyError(key)
510
511 try:
512 return Index.get_loc(self, key)
513 except KeyError as err:
514 raise KeyError(orig_key) from err
515
516 def _disallow_mismatched_indexing(self, key: Period) -> None:
517 if key._dtype != self.dtype:
518 raise KeyError(key)
519
520 def _cast_partial_indexing_scalar(self, label: datetime) -> Period:
521 try:
522 period = Period(label, freq=self.freq)
523 except ValueError as err:
524 # we cannot construct the Period
525 raise KeyError(label) from err
526 return period
527
528 @doc(DatetimeIndexOpsMixin._maybe_cast_slice_bound)
529 def _maybe_cast_slice_bound(self, label, side: str):
530 if isinstance(label, datetime):
531 label = self._cast_partial_indexing_scalar(label)
532
533 return super()._maybe_cast_slice_bound(label, side)
534
535 def _parsed_string_to_bounds(self, reso: Resolution, parsed: datetime):
536 freq = OFFSET_TO_PERIOD_FREQSTR.get(reso.attr_abbrev, reso.attr_abbrev)
537 iv = Period(parsed, freq=freq)
538 return (iv.asfreq(self.freq, how="start"), iv.asfreq(self.freq, how="end"))
539
540 @doc(DatetimeIndexOpsMixin.shift)
541 def shift(self, periods: int = 1, freq=None) -> Self:
542 if freq is not None:
543 raise TypeError(
544 f"`freq` argument is not supported for {type(self).__name__}.shift"
545 )
546 return self + periods
547
548
549@set_module("pandas")
550def period_range(
551 start=None,
552 end=None,
553 periods: int | None = None,
554 freq=None,
555 name: Hashable | None = None,
556) -> PeriodIndex:
557 """
558 Return a fixed frequency PeriodIndex.
559
560 The day (calendar) is the default frequency.
561
562 Parameters
563 ----------
564 start : str, datetime, date, pandas.Timestamp, or period-like, default None
565 Left bound for generating periods.
566 end : str, datetime, date, pandas.Timestamp, or period-like, default None
567 Right bound for generating periods.
568 periods : int, default None
569 Number of periods to generate.
570 freq : str or DateOffset, optional
571 Frequency alias. By default the freq is taken from `start` or `end`
572 if those are Period objects. Otherwise, the default is ``"D"`` for
573 daily frequency.
574 name : str, default None
575 Name of the resulting PeriodIndex.
576
577 Returns
578 -------
579 PeriodIndex
580 A PeriodIndex of fixed frequency periods.
581
582 See Also
583 --------
584 date_range : Returns a fixed frequency DatetimeIndex.
585 Period : Represents a period of time.
586 PeriodIndex : Immutable ndarray holding ordinal values indicating regular periods
587 in time.
588
589 Notes
590 -----
591 Of the three parameters: ``start``, ``end``, and ``periods``, exactly two
592 must be specified.
593
594 To learn more about the frequency strings, please see
595 :ref:`this link<timeseries.offset_aliases>`.
596
597 Examples
598 --------
599 >>> pd.period_range(start="2017-01-01", end="2018-01-01", freq="M")
600 PeriodIndex(['2017-01', '2017-02', '2017-03', '2017-04', '2017-05', '2017-06',
601 '2017-07', '2017-08', '2017-09', '2017-10', '2017-11', '2017-12',
602 '2018-01'],
603 dtype='period[M]')
604
605 If ``start`` or ``end`` are ``Period`` objects, they will be used as anchor
606 endpoints for a ``PeriodIndex`` with frequency matching that of the
607 ``period_range`` constructor.
608
609 >>> pd.period_range(
610 ... start=pd.Period("2017Q1", freq="Q"),
611 ... end=pd.Period("2017Q2", freq="Q"),
612 ... freq="M",
613 ... )
614 PeriodIndex(['2017-03', '2017-04', '2017-05', '2017-06'],
615 dtype='period[M]')
616 """
617 if com.count_not_none(start, end, periods) != 2:
618 raise ValueError(
619 "Of the three parameters: start, end, and periods, "
620 "exactly two must be specified"
621 )
622 if freq is None and (not isinstance(start, Period) and not isinstance(end, Period)):
623 freq = "D"
624
625 data, freq = PeriodArray._generate_range(start, end, periods, freq)
626 dtype = PeriodDtype(freq)
627 data = PeriodArray(data, dtype=dtype)
628 return PeriodIndex(data, name=name, copy=False)