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