1from __future__ import annotations
2
3from typing import TYPE_CHECKING
4import warnings
5
6import numpy as np
7
8from pandas._config import using_infer_freq_offset
9
10from pandas._libs import lib
11from pandas._libs.algos import unique_deltas
12from pandas._libs.tslibs import (
13 Timestamp,
14 get_unit_from_dtype,
15 periods_per_day,
16 tz_convert_from_utc,
17)
18from pandas._libs.tslibs.ccalendar import (
19 MONTH_ALIASES,
20 MONTH_NUMBERS,
21 int_to_weekday,
22)
23from pandas._libs.tslibs.dtypes import OFFSET_TO_PERIOD_FREQSTR
24from pandas._libs.tslibs.fields import (
25 build_field_sarray,
26 month_position_check,
27)
28from pandas._libs.tslibs.offsets import (
29 BaseOffset,
30 Day,
31 to_offset,
32)
33from pandas._libs.tslibs.parsing import get_rule_month
34from pandas.errors import Pandas4Warning
35from pandas.util._decorators import (
36 cache_readonly,
37 set_module,
38)
39from pandas.util._exceptions import find_stack_level
40
41from pandas.core.dtypes.common import is_numeric_dtype
42from pandas.core.dtypes.dtypes import (
43 ArrowDtype,
44 DatetimeTZDtype,
45 PeriodDtype,
46)
47from pandas.core.dtypes.generic import (
48 ABCIndex,
49 ABCSeries,
50)
51
52if TYPE_CHECKING:
53 from pandas._typing import npt
54
55 from pandas import (
56 DatetimeIndex,
57 Series,
58 TimedeltaIndex,
59 )
60 from pandas.core.arrays.datetimelike import DatetimeLikeArrayMixin
61# --------------------------------------------------------------------
62# Offset related functions
63
64
65def get_period_alias(offset_str: str) -> str | None:
66 """
67 Alias to closest period strings BQ->Q etc.
68 """
69 return OFFSET_TO_PERIOD_FREQSTR.get(offset_str, None)
70
71
72def maybe_convert_inferred_freq(
73 freq_str: str | None, caller: str, expr: str | None = None
74) -> str | BaseOffset | None:
75 """
76 Implement the GH#55504 deprecation of inferred frequencies being returned
77 as strings rather than BaseOffset objects.
78
79 Parameters
80 ----------
81 freq_str : str or None
82 The inferred frequency, as returned by infer_freq_str.
83 caller : str
84 User-facing name of the attribute or function being called, used in
85 the warning message.
86 expr : str, optional
87 Expression the user can append ``.freqstr`` to in order to keep the
88 string result. Defaults to `caller`.
89
90 Returns
91 -------
92 str, BaseOffset, or None
93 """
94 if freq_str is None:
95 # The result is None either way, so there is no behavior change
96 # to warn about.
97 return None
98
99 opt = using_infer_freq_offset()
100 if opt is True:
101 return to_offset(freq_str)
102 if opt is None:
103 warnings.warn(
104 f"A future version of pandas will return a BaseOffset object "
105 f"instead of a string from {caller}. "
106 f"Use pd.set_option('future.infer_freq_returns_offset', True) "
107 f"to get the future behavior, or set to False to keep the old "
108 f"behavior and silence this warning. To preserve the string "
109 f"representation, use ``{expr or caller}.freqstr``.",
110 Pandas4Warning,
111 stacklevel=find_stack_level(),
112 )
113 return freq_str
114
115
116# ---------------------------------------------------------------------
117# Period codes
118
119
120def infer_freq_str(
121 index: DatetimeIndex | TimedeltaIndex | Series | DatetimeLikeArrayMixin,
122) -> str | None:
123 """
124 Internal version of infer_freq that returns a string without
125 emitting a deprecation warning.
126 """
127 from pandas.core.api import DatetimeIndex
128
129 if isinstance(index, ABCSeries):
130 values = index._values
131
132 if isinstance(index.dtype, ArrowDtype):
133 import pyarrow as pa
134
135 if pa.types.is_timestamp(values.dtype.pyarrow_dtype):
136 # GH#58403
137 values = values._to_datetimearray()
138
139 if not (
140 lib.is_np_dtype(values.dtype, "mM")
141 or isinstance(values.dtype, DatetimeTZDtype)
142 or values.dtype == object
143 ):
144 raise TypeError(
145 "cannot infer freq from a non-convertible dtype "
146 f"on a Series of {index.dtype}"
147 )
148 index = values
149
150 inferer: _FrequencyInferer
151
152 if not hasattr(index, "dtype"):
153 pass
154 elif isinstance(index.dtype, PeriodDtype):
155 raise TypeError(
156 "PeriodIndex given. Check the `freq` attribute instead of using infer_freq."
157 )
158 elif lib.is_np_dtype(index.dtype, "m"):
159 # Allow TimedeltaIndex and TimedeltaArray
160 inferer = _TimedeltaFrequencyInferer(index)
161 return inferer.get_freq()
162
163 elif is_numeric_dtype(index.dtype):
164 raise TypeError(
165 f"cannot infer freq from a non-convertible index of dtype {index.dtype}"
166 )
167
168 if not isinstance(index, DatetimeIndex):
169 index = DatetimeIndex(index, copy=False)
170
171 inferer = _FrequencyInferer(index)
172 return inferer.get_freq()
173
174
175@set_module("pandas")
176def infer_freq(
177 index: DatetimeIndex | TimedeltaIndex | Series | DatetimeLikeArrayMixin,
178) -> str | BaseOffset | None:
179 """
180 Infer the most likely frequency given the input index.
181
182 .. deprecated:: 3.1.0
183 A future version of pandas will return a :class:`BaseOffset` instead of
184 a string. Use
185 ``pd.set_option('future.infer_freq_returns_offset', True)`` to opt in
186 to the future behavior.
187
188 This method attempts to deduce the most probable frequency (e.g., 'D' for daily,
189 'H' for hourly) from a sequence of datetime-like objects. It is particularly useful
190 when the frequency of a time series is not explicitly set or known but can be
191 inferred from its values.
192
193 Parameters
194 ----------
195 index : DatetimeIndex, TimedeltaIndex, Series or array-like
196 If passed a Series will use the values of the series (NOT THE INDEX).
197
198 Returns
199 -------
200 str or None
201 None if no discernible frequency.
202
203 Raises
204 ------
205 TypeError
206 If the index is not datetime-like.
207 ValueError
208 If there are fewer than three values.
209
210 See Also
211 --------
212 date_range : Return a fixed frequency DatetimeIndex.
213 timedelta_range : Return a fixed frequency TimedeltaIndex with day as the default.
214 period_range : Return a fixed frequency PeriodIndex.
215 DatetimeIndex.freq : Return the frequency object if it is set, otherwise None.
216
217 Examples
218 --------
219 >>> idx = pd.date_range(start="2020/12/01", end="2020/12/30", periods=30)
220 >>> pd.infer_freq(idx) # doctest: +SKIP
221 'D'
222 """
223 return maybe_convert_inferred_freq(
224 infer_freq_str(index), "infer_freq", expr="infer_freq(...)"
225 )
226
227
228class _FrequencyInferer:
229 """
230 Not sure if I can avoid the state machine here
231 """
232
233 def __init__(self, index) -> None:
234 self.index = index
235 self.i8values = index.asi8
236
237 # For get_unit_from_dtype we need the dtype to the underlying ndarray,
238 # which for tz-aware is not the same as index.dtype
239 if isinstance(index, ABCIndex):
240 # error: Item "ndarray[Any, Any]" of "Union[ExtensionArray,
241 # ndarray[Any, Any]]" has no attribute "_ndarray"
242 self._creso = get_unit_from_dtype(
243 index._data._ndarray.dtype # type: ignore[union-attr]
244 )
245 else:
246 # otherwise we have DTA/TDA
247 self._creso = get_unit_from_dtype(index._ndarray.dtype)
248
249 # This moves the values, which are implicitly in UTC, to the
250 # the timezone so they are in local time
251 if hasattr(index, "tz"):
252 if index.tz is not None:
253 self.i8values = tz_convert_from_utc(
254 self.i8values, index.tz, reso=self._creso
255 )
256
257 if len(index) < 3:
258 raise ValueError("Need at least 3 dates to infer frequency")
259
260 self.is_monotonic = (
261 self.index._is_monotonic_increasing or self.index._is_monotonic_decreasing
262 )
263
264 @cache_readonly
265 def deltas(self) -> npt.NDArray[np.int64]:
266 return unique_deltas(self.i8values)
267
268 @cache_readonly
269 def deltas_asi8(self) -> npt.NDArray[np.int64]:
270 # NB: we cannot use self.i8values here because we may have converted
271 # the tz in __init__
272 return unique_deltas(self.index.asi8)
273
274 @cache_readonly
275 def is_unique(self) -> bool:
276 return len(self.deltas) == 1
277
278 @cache_readonly
279 def is_unique_asi8(self) -> bool:
280 return len(self.deltas_asi8) == 1
281
282 def get_freq(self) -> str | None:
283 """
284 Find the appropriate frequency string to describe the inferred
285 frequency of self.i8values
286
287 Returns
288 -------
289 str or None
290 """
291 if not self.is_monotonic or not self.index._is_unique:
292 return None
293
294 delta = self.deltas[0]
295 ppd = periods_per_day(self._creso)
296 if delta and _is_multiple(delta, ppd):
297 return self._infer_daily_rule()
298
299 # Business hourly, maybe. 17: one day / 65: one weekend
300 if self.hour_deltas in ([1, 17], [1, 65], [1, 17, 65]):
301 return "bh"
302
303 # Possibly intraday frequency. Here we use the
304 # original .asi8 values as the modified values
305 # will not work around DST transitions. See #8772
306 if not self.is_unique_asi8:
307 return None
308
309 delta = self.deltas_asi8[0]
310 pph = ppd // 24
311 ppm = pph // 60
312 pps = ppm // 60
313 if _is_multiple(delta, pph):
314 # Hours
315 return _maybe_add_count("h", delta / pph)
316 elif _is_multiple(delta, ppm):
317 # Minutes
318 return _maybe_add_count("min", delta / ppm)
319 elif _is_multiple(delta, pps):
320 # Seconds
321 return _maybe_add_count("s", delta / pps)
322 elif _is_multiple(delta, (pps // 1000)):
323 # Milliseconds
324 return _maybe_add_count("ms", delta / (pps // 1000))
325 elif _is_multiple(delta, (pps // 1_000_000)):
326 # Microseconds
327 return _maybe_add_count("us", delta / (pps // 1_000_000))
328 else:
329 # Nanoseconds
330 return _maybe_add_count("ns", delta)
331
332 @cache_readonly
333 def day_deltas(self) -> list[float]:
334 # NB: must be true division; flooring would let a fractional gap
335 # match the exact [1, 3] check in _is_business_daily
336 ppd = periods_per_day(self._creso)
337 return [x / ppd for x in self.deltas]
338
339 @cache_readonly
340 def hour_deltas(self) -> list[float]:
341 # NB: must be true division, as in day_deltas; this one feeds the
342 # exact [1, 17]/[1, 65] check in get_freq
343 pph = periods_per_day(self._creso) // 24
344 return [x / pph for x in self.deltas]
345
346 @cache_readonly
347 def fields(self) -> np.ndarray: # structured array of fields
348 return build_field_sarray(self.i8values, reso=self._creso)
349
350 @cache_readonly
351 def rep_stamp(self) -> Timestamp:
352 # creating a Timestamp preserving the i8 unit
353 return Timestamp._from_value_and_reso(self.i8values[0], self._creso, None)
354
355 def month_position_check(self) -> str | None:
356 return month_position_check(self.fields, self.index.day_of_week)
357
358 @cache_readonly
359 def mdiffs(self) -> npt.NDArray[np.int64]:
360 nmonths = self.fields["Y"] * 12 + self.fields["M"]
361 return unique_deltas(nmonths.astype("i8"))
362
363 @cache_readonly
364 def ydiffs(self) -> npt.NDArray[np.int64]:
365 return unique_deltas(self.fields["Y"].astype("i8"))
366
367 def _infer_daily_rule(self) -> str | None:
368 annual_rule = self._get_annual_rule()
369 if annual_rule:
370 nyears = self.ydiffs[0]
371 month = MONTH_ALIASES[self.rep_stamp.month]
372 alias = f"{annual_rule}-{month}"
373 return _maybe_add_count(alias, nyears)
374
375 quarterly_rule = self._get_quarterly_rule()
376 if quarterly_rule:
377 nquarters = self.mdiffs[0] / 3
378 if quarterly_rule in ("QS", "BQS"):
379 # GH#36939 the anchor month is only known up to mod 3; use the
380 # first-calendar-quarter representative so that to_period on
381 # e.g. Jan/Apr/Jul/Oct starts (QS-JAN) keeps the calendar
382 # convention Q-DEC
383 mod_dict = {1: 1, 2: 2, 0: 3}
384 else:
385 mod_dict = {0: 12, 2: 11, 1: 10}
386 month = MONTH_ALIASES[mod_dict[self.rep_stamp.month % 3]]
387 alias = f"{quarterly_rule}-{month}"
388 return _maybe_add_count(alias, nquarters)
389
390 monthly_rule = self._get_monthly_rule()
391 if monthly_rule:
392 return _maybe_add_count(monthly_rule, self.mdiffs[0])
393
394 if self.is_unique:
395 return self._get_daily_rule()
396
397 if self._is_business_daily():
398 return "B"
399
400 wom_rule = self._get_wom_rule()
401 if wom_rule:
402 return wom_rule
403
404 return None
405
406 def _get_daily_rule(self) -> str | None:
407 ppd = periods_per_day(self._creso)
408 days = self.deltas[0] / ppd
409 if days % 7 == 0:
410 # Weekly
411 wd = int_to_weekday[self.rep_stamp.weekday()]
412 alias = f"W-{wd}"
413 return _maybe_add_count(alias, days / 7)
414 else:
415 return _maybe_add_count("D", days)
416
417 def _get_annual_rule(self) -> str | None:
418 if len(self.ydiffs) > 1:
419 return None
420
421 if len(np.unique(self.fields["M"])) > 1:
422 return None
423
424 pos_check = self.month_position_check()
425
426 if pos_check is None:
427 return None
428 else:
429 return {"cs": "YS", "bs": "BYS", "ce": "YE", "be": "BYE"}.get(pos_check)
430
431 def _get_quarterly_rule(self) -> str | None:
432 if len(self.mdiffs) > 1:
433 return None
434
435 if not self.mdiffs[0] % 3 == 0:
436 return None
437
438 pos_check = self.month_position_check()
439
440 if pos_check is None:
441 return None
442 else:
443 return {"cs": "QS", "bs": "BQS", "ce": "QE", "be": "BQE"}.get(pos_check)
444
445 def _get_monthly_rule(self) -> str | None:
446 if len(self.mdiffs) > 1:
447 return None
448 pos_check = self.month_position_check()
449
450 if pos_check is None:
451 return None
452 else:
453 return {"cs": "MS", "bs": "BMS", "ce": "ME", "be": "BME"}.get(pos_check)
454
455 def _is_business_daily(self) -> bool:
456 # quick check: cannot be business daily
457 if self.day_deltas != [1, 3]:
458 return False
459
460 # probably business daily, but need to confirm
461 first_weekday = self.index[0].weekday()
462 shifts = np.diff(self.i8values)
463 ppd = periods_per_day(self._creso)
464 shifts = np.floor_divide(shifts, ppd)
465 weekdays = np.mod(first_weekday + np.cumsum(shifts), 7)
466
467 return bool(
468 np.all(
469 ((weekdays == 0) & (shifts == 3))
470 | ((weekdays > 0) & (weekdays <= 4) & (shifts == 1))
471 )
472 )
473
474 def _get_wom_rule(self) -> str | None:
475 weekdays = np.unique(self.index.day_of_week)
476 if len(weekdays) > 1:
477 return None
478
479 week_of_months = np.unique((self.index.day - 1) // 7)
480 # Only attempt to infer up to WOM-4. See #9425
481 week_of_months = week_of_months[week_of_months < 4]
482 if len(week_of_months) == 0 or len(week_of_months) > 1:
483 return None
484
485 # get which week
486 week = week_of_months[0] + 1
487 wd = int_to_weekday[weekdays[0]]
488
489 return f"WOM-{week}{wd}"
490
491
492class _TimedeltaFrequencyInferer(_FrequencyInferer):
493 def _infer_daily_rule(self):
494 if self.is_unique:
495 return self._get_daily_rule()
496
497
498def _is_multiple(us, mult: int) -> bool:
499 return us % mult == 0
500
501
502def _maybe_add_count(base: str, count: float) -> str:
503 if count != 1:
504 assert count == int(count)
505 count = int(count)
506 return f"{count}{base}"
507 else:
508 return base
509
510
511# ----------------------------------------------------------------------
512# Frequency comparison
513
514
515def is_subperiod(source, target) -> bool:
516 """
517 Returns True if downsampling is possible between source and target
518 frequencies
519
520 Parameters
521 ----------
522 source : str or DateOffset
523 Frequency converting from
524 target : str or DateOffset
525 Frequency converting to
526
527 Returns
528 -------
529 bool
530 """
531 if target is None or source is None:
532 return False
533 source = _maybe_coerce_freq(source)
534 target = _maybe_coerce_freq(target)
535
536 if _is_annual(target):
537 if _is_annual(source):
538 return get_rule_month(source) == get_rule_month(target)
539 if _is_quarterly(source):
540 return _quarter_months_conform(
541 get_rule_month(source), get_rule_month(target)
542 )
543 return source in {"D", "C", "B", "M", "h", "min", "s", "ms", "us", "ns"}
544 elif _is_quarterly(target):
545 if _is_quarterly(source):
546 return _quarter_months_conform(
547 get_rule_month(source), get_rule_month(target)
548 )
549 return source in {"D", "C", "B", "M", "h", "min", "s", "ms", "us", "ns"}
550 elif _is_monthly(target):
551 return source in {target, "D", "C", "B", "h", "min", "s", "ms", "us", "ns"}
552 elif _is_weekly(target):
553 return source in {target, "D", "C", "B", "h", "min", "s", "ms", "us", "ns"}
554 elif target == "B":
555 return source in {"B", "h", "min", "s", "ms", "us", "ns"}
556 elif target == "C":
557 return source in {"C", "h", "min", "s", "ms", "us", "ns"}
558 elif target == "D":
559 return source in {"D", "h", "min", "s", "ms", "us", "ns"}
560 elif target == "h":
561 return source in {"h", "min", "s", "ms", "us", "ns"}
562 elif target == "min":
563 return source in {"min", "s", "ms", "us", "ns"}
564 elif target == "s":
565 return source in {"s", "ms", "us", "ns"}
566 elif target == "ms":
567 return source in {"ms", "us", "ns"}
568 elif target == "us":
569 return source in {"us", "ns"}
570 elif target == "ns":
571 return source in {"ns"}
572 else:
573 return False
574
575
576def is_superperiod(source, target) -> bool:
577 """
578 Returns True if upsampling is possible between source and target
579 frequencies
580
581 Parameters
582 ----------
583 source : str or DateOffset
584 Frequency converting from
585 target : str or DateOffset
586 Frequency converting to
587
588 Returns
589 -------
590 bool
591 """
592 if target is None or source is None:
593 return False
594 source = _maybe_coerce_freq(source)
595 target = _maybe_coerce_freq(target)
596
597 if _is_annual(source):
598 if _is_annual(target):
599 return get_rule_month(source) == get_rule_month(target)
600
601 if _is_quarterly(target):
602 smonth = get_rule_month(source)
603 tmonth = get_rule_month(target)
604 return _quarter_months_conform(smonth, tmonth)
605 return target in {"D", "C", "B", "M", "h", "min", "s", "ms", "us", "ns"}
606 elif _is_quarterly(source):
607 if _is_quarterly(target):
608 return _quarter_months_conform(
609 get_rule_month(source), get_rule_month(target)
610 )
611 return target in {"D", "C", "B", "M", "h", "min", "s", "ms", "us", "ns"}
612 elif _is_monthly(source):
613 return target in {source, "D", "C", "B", "h", "min", "s", "ms", "us", "ns"}
614 elif _is_weekly(source):
615 return target in {source, "D", "C", "B", "h", "min", "s", "ms", "us", "ns"}
616 elif source == "B":
617 return target in {"D", "C", "B", "h", "min", "s", "ms", "us", "ns"}
618 elif source == "C":
619 return target in {"D", "C", "B", "h", "min", "s", "ms", "us", "ns"}
620 elif source == "D":
621 return target in {"D", "C", "B", "h", "min", "s", "ms", "us", "ns"}
622 elif source == "h":
623 return target in {"h", "min", "s", "ms", "us", "ns"}
624 elif source == "min":
625 return target in {"min", "s", "ms", "us", "ns"}
626 elif source == "s":
627 return target in {"s", "ms", "us", "ns"}
628 elif source == "ms":
629 return target in {"ms", "us", "ns"}
630 elif source == "us":
631 return target in {"us", "ns"}
632 elif source == "ns":
633 return target in {"ns"}
634 else:
635 return False
636
637
638def _maybe_coerce_freq(code) -> str:
639 """we might need to coerce a code to a rule_code
640 and uppercase it
641
642 Parameters
643 ----------
644 code : str or DateOffset
645 Frequency converting from
646
647 Returns
648 -------
649 str
650 """
651 assert code is not None
652 if isinstance(code, BaseOffset):
653 code = PeriodDtype(to_offset(code.rule_code))._freqstr
654 # Strip any leading multiplier digits (e.g. '12h' -> 'h') so that
655 # is_subperiod / is_superperiod can compare base frequency codes. GH#50355
656 code = code.lstrip("0123456789")
657 if code in {"h", "min", "s", "ms", "us", "ns"}:
658 return code
659 else:
660 return code.upper()
661
662
663def _quarter_months_conform(source: str, target: str) -> bool:
664 snum = MONTH_NUMBERS[source]
665 tnum = MONTH_NUMBERS[target]
666 return snum % 3 == tnum % 3
667
668
669def _is_annual(rule: str) -> bool:
670 rule = rule.upper()
671 return rule == "Y" or rule.startswith("Y-")
672
673
674def _is_quarterly(rule: str) -> bool:
675 rule = rule.upper()
676 return rule == "Q" or rule.startswith(("Q-", "BQ"))
677
678
679def _is_monthly(rule: str) -> bool:
680 rule = rule.upper()
681 return rule in ("M", "BM")
682
683
684def _is_weekly(rule: str) -> bool:
685 rule = rule.upper()
686 return rule == "W" or rule.startswith("W-")
687
688
689__all__ = [
690 "Day",
691 "get_period_alias",
692 "infer_freq",
693 "is_subperiod",
694 "is_superperiod",
695 "to_offset",
696]