Skip to content

Commit ca96e15

Browse files
committed
implement business_start, business_end for shift_months
1 parent d101064 commit ca96e15

File tree

5 files changed

+160
-45
lines changed

5 files changed

+160
-45
lines changed

pandas/_libs/tslibs/ccalendar.pxd

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# -*- coding: utf-8 -*-
2+
# cython: profile=False
3+
4+
from cython cimport Py_ssize_t
5+
6+
from numpy cimport int64_t, int32_t
7+
8+
9+
cpdef monthrange(int64_t year, Py_ssize_t month)
10+
11+
cdef int dayofweek(int y, int m, int d) nogil
12+
cdef int is_leapyear(int64_t year) nogil
13+
cdef int32_t get_days_in_month(int year, int month) nogil

pandas/_libs/tslibs/ccalendar.pyx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# -*- coding: utf-8 -*-
2+
# cython: profile=False
3+
# cython: boundscheck=False
4+
"""
5+
Cython implementations of functions resembling the stdlib calendar module
6+
"""
7+
8+
cimport cython
9+
from cython cimport Py_ssize_t
10+
11+
import numpy as np
12+
cimport numpy as np
13+
from numpy cimport int64_t, int32_t
14+
np.import_array()
15+
16+
17+
# ----------------------------------------------------------------------
18+
# Constants
19+
20+
# Slightly more performant cython lookups than a 2D table
21+
cdef int32_t* days_per_month_array = [
22+
31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
23+
31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
24+
25+
# ----------------------------------------------------------------------
26+
27+
28+
@cython.wraparound(False)
29+
@cython.boundscheck(False)
30+
cdef inline int32_t get_days_in_month(int year, int month) nogil:
31+
return days_per_month_array[12 * is_leapyear(year) + month - 1]
32+
33+
34+
@cython.wraparound(False)
35+
@cython.boundscheck(False)
36+
cpdef monthrange(int64_t year, Py_ssize_t month):
37+
cdef:
38+
int32_t days
39+
40+
if month < 1 or month > 12:
41+
raise ValueError("bad month number 0; must be 1-12")
42+
43+
days = get_days_in_month(year, month)
44+
return (dayofweek(year, month, 1), days)
45+
46+
47+
@cython.wraparound(False)
48+
@cython.boundscheck(False)
49+
@cython.cdivision
50+
cdef int dayofweek(int y, int m, int d) nogil:
51+
"""Sakamoto's method, from wikipedia"""
52+
cdef:
53+
int day
54+
int* sakamoto_arr = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]
55+
56+
y -= m < 3
57+
day = (y + y / 4 - y / 100 + y / 400 + sakamoto_arr[m - 1] + d) % 7
58+
# convert to python day
59+
return (day + 6) % 7
60+
61+
62+
cdef int is_leapyear(int64_t year) nogil:
63+
"""Returns 1 if the given year is a leap year, 0 otherwise."""
64+
return ((year & 0x3) == 0 and # year % 4 == 0
65+
((year % 100) != 0 or (year % 400) == 0))

pandas/_libs/tslibs/offsets.pyx

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,12 @@ np.import_array()
1717

1818
from util cimport is_string_object, is_integer_object
1919

20-
from pandas._libs.tslib import monthrange
21-
20+
from ccalendar cimport get_days_in_month, monthrange
2221
from conversion cimport tz_convert_single, pydt_to_i8
2322
from frequencies cimport get_freq_code
2423
from nattype cimport NPY_NAT
2524
from np_datetime cimport (pandas_datetimestruct,
26-
dtstruct_to_dt64, dt64_to_dtstruct,
27-
is_leapyear, days_per_month_table)
25+
dtstruct_to_dt64, dt64_to_dtstruct)
2826

2927
# ---------------------------------------------------------------------
3028
# Constants
@@ -452,11 +450,6 @@ class BaseOffset(_BaseOffset):
452450
# ----------------------------------------------------------------------
453451
# RelativeDelta Arithmetic
454452

455-
@cython.wraparound(False)
456-
@cython.boundscheck(False)
457-
cdef inline int get_days_in_month(int year, int month) nogil:
458-
return days_per_month_table[is_leapyear(year)][month - 1]
459-
460453

461454
cdef inline int year_add_months(pandas_datetimestruct dts, int months) nogil:
462455
"""new year number after shifting pandas_datetimestruct number of months"""
@@ -554,8 +547,58 @@ def shift_months(int64_t[:] dtindex, int months, object day=None):
554547

555548
dts.day = get_days_in_month(dts.year, dts.month)
556549
out[i] = dtstruct_to_dt64(&dts)
550+
551+
elif day == 'business_start':
552+
for i in range(count):
553+
if dtindex[i] == NPY_NAT:
554+
out[i] = NPY_NAT
555+
continue
556+
557+
dt64_to_dtstruct(dtindex[i], &dts)
558+
months_to_roll = months
559+
wkday, days_in_month = monthrange(dts.year, dts.month)
560+
compare_day = get_firstbday(wkday, days_in_month)
561+
562+
if months_to_roll > 0 and dts.day < compare_day:
563+
months_to_roll -= 1
564+
elif months_to_roll <= 0 and dts.day > compare_day:
565+
# as if rolled forward already
566+
months_to_roll += 1
567+
568+
dts.year = year_add_months(dts, months_to_roll)
569+
dts.month = month_add_months(dts, months_to_roll)
570+
571+
wkday, days_in_month = monthrange(dts.year, dts.month)
572+
dts.day = get_firstbday(wkday, days_in_month)
573+
out[i] = dtstruct_to_dt64(&dts)
574+
575+
elif day == 'business_end':
576+
for i in range(count):
577+
if dtindex[i] == NPY_NAT:
578+
out[i] = NPY_NAT
579+
continue
580+
581+
dt64_to_dtstruct(dtindex[i], &dts)
582+
months_to_roll = months
583+
wkday, days_in_month = monthrange(dts.year, dts.month)
584+
compare_day = get_lastbday(wkday, days_in_month)
585+
586+
if months_to_roll > 0 and dts.day < compare_day:
587+
months_to_roll -= 1
588+
elif months_to_roll <= 0 and dts.day > compare_day:
589+
# as if rolled forward already
590+
months_to_roll += 1
591+
592+
dts.year = year_add_months(dts, months_to_roll)
593+
dts.month = month_add_months(dts, months_to_roll)
594+
595+
wkday, days_in_month = monthrange(dts.year, dts.month)
596+
dts.day = get_lastbday(wkday, days_in_month)
597+
out[i] = dtstruct_to_dt64(&dts)
598+
557599
else:
558-
raise ValueError("day must be None, 'start' or 'end'")
600+
raise ValueError("day must be None, 'start', 'end', "
601+
"'business_start', or 'business_end'")
559602

560603
return np.asarray(out)
561604

pandas/tseries/offsets.py

Lines changed: 25 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -929,8 +929,9 @@ def name(self):
929929
if self.isAnchored:
930930
return self.rule_code
931931
else:
932+
month = liboffsets._int_to_month[self.n]
932933
return "{code}-{month}".format(code=self.rule_code,
933-
month=_int_to_month[self.n])
934+
month=month)
934935

935936
def onOffset(self, dt):
936937
if self.normalize and not _is_normalized(dt):
@@ -950,28 +951,23 @@ def apply(self, other):
950951

951952
return shift_month(other, n, self._day_opt)
952953

954+
@apply_index_wraps
955+
def apply_index(self, i):
956+
shifted = liboffsets.shift_months(i.asi8, self.n, self._day_opt)
957+
return i._shallow_copy(shifted)
958+
953959

954960
class MonthEnd(MonthOffset):
955961
"""DateOffset of one month end"""
956962
_prefix = 'M'
957963
_day_opt = 'end'
958964

959-
@apply_index_wraps
960-
def apply_index(self, i):
961-
shifted = liboffsets.shift_months(i.asi8, self.n, self._day_opt)
962-
return i._shallow_copy(shifted)
963-
964965

965966
class MonthBegin(MonthOffset):
966967
"""DateOffset of one month at beginning"""
967968
_prefix = 'MS'
968969
_day_opt = 'start'
969970

970-
@apply_index_wraps
971-
def apply_index(self, i):
972-
shifted = liboffsets.shift_months(i.asi8, self.n, self._day_opt)
973-
return i._shallow_copy(shifted)
974-
975971

976972
class BusinessMonthEnd(MonthOffset):
977973
"""DateOffset increments between business EOM dates"""
@@ -1008,6 +1004,7 @@ class CustomBusinessMonthEnd(BusinessMixin, MonthOffset):
10081004
_prefix = 'CBM'
10091005

10101006
onOffset = DateOffset.onOffset # override MonthOffset method
1007+
apply_index = DateOffset.apply_index # override MonthOffset method
10111008

10121009
def __init__(self, n=1, normalize=False, weekmask='Mon Tue Wed Thu Fri',
10131010
holidays=None, calendar=None, offset=timedelta(0)):
@@ -1083,6 +1080,7 @@ class CustomBusinessMonthBegin(BusinessMixin, MonthOffset):
10831080
_prefix = 'CBMS'
10841081

10851082
onOffset = DateOffset.onOffset # override MonthOffset method
1083+
apply_index = DateOffset.apply_index # override MonthOffset method
10861084

10871085
def __init__(self, n=1, normalize=False, weekmask='Mon Tue Wed Thu Fri',
10881086
holidays=None, calendar=None, offset=timedelta(0)):
@@ -1603,15 +1601,15 @@ def isAnchored(self):
16031601
def _from_name(cls, suffix=None):
16041602
kwargs = {}
16051603
if suffix:
1606-
kwargs['startingMonth'] = _month_to_int[suffix]
1604+
kwargs['startingMonth'] = liboffsets._month_to_int[suffix]
16071605
else:
16081606
if cls._from_name_startingMonth is not None:
16091607
kwargs['startingMonth'] = cls._from_name_startingMonth
16101608
return cls(**kwargs)
16111609

16121610
@property
16131611
def rule_code(self):
1614-
month = _int_to_month[self.startingMonth]
1612+
month = liboffsets._int_to_month[self.startingMonth]
16151613
return '{prefix}-{month}'.format(prefix=self._prefix, month=month)
16161614

16171615
@apply_wraps
@@ -1631,6 +1629,12 @@ def apply(self, other):
16311629

16321630
return shift_month(other, 3 * n - months_since, self._day_opt)
16331631

1632+
def onOffset(self, dt):
1633+
if self.normalize and not _is_normalized(dt):
1634+
return False
1635+
modMonth = (dt.month - self.startingMonth) % 3
1636+
return modMonth == 0 and dt.day == self._get_offset_day(dt)
1637+
16341638

16351639
class BQuarterEnd(QuarterOffset):
16361640
"""DateOffset increments between business Quarter dates
@@ -1644,16 +1648,6 @@ class BQuarterEnd(QuarterOffset):
16441648
_prefix = 'BQ'
16451649
_day_opt = 'business_end'
16461650

1647-
def onOffset(self, dt):
1648-
if self.normalize and not _is_normalized(dt):
1649-
return False
1650-
modMonth = (dt.month - self.startingMonth) % 3
1651-
return modMonth == 0 and dt.day == self._get_offset_day(dt)
1652-
1653-
1654-
_int_to_month = tslib._MONTH_ALIASES
1655-
_month_to_int = {v: k for k, v in _int_to_month.items()}
1656-
16571651

16581652
# TODO: This is basically the same as BQuarterEnd
16591653
class BQuarterBegin(QuarterOffset):
@@ -1680,12 +1674,6 @@ class QuarterEnd(EndMixin, QuarterOffset):
16801674
def apply_index(self, i):
16811675
return self._end_apply_index(i, self.freqstr)
16821676

1683-
def onOffset(self, dt):
1684-
if self.normalize and not _is_normalized(dt):
1685-
return False
1686-
modMonth = (dt.month - self.startingMonth) % 3
1687-
return modMonth == 0 and dt.day == self._get_offset_day(dt)
1688-
16891677

16901678
class QuarterBegin(BeginMixin, QuarterOffset):
16911679
_outputName = 'QuarterBegin'
@@ -1697,7 +1685,8 @@ class QuarterBegin(BeginMixin, QuarterOffset):
16971685
@apply_index_wraps
16981686
def apply_index(self, i):
16991687
freq_month = 12 if self.startingMonth == 1 else self.startingMonth - 1
1700-
freqstr = 'Q-{month}'.format(month=_int_to_month[freq_month])
1688+
month = liboffsets._int_to_month[freq_month]
1689+
freqstr = 'Q-{month}'.format(month=month)
17011690
return self._beg_apply_index(i, freqstr)
17021691

17031692

@@ -1738,12 +1727,12 @@ def __init__(self, n=1, normalize=False, month=None):
17381727
def _from_name(cls, suffix=None):
17391728
kwargs = {}
17401729
if suffix:
1741-
kwargs['month'] = _month_to_int[suffix]
1730+
kwargs['month'] = liboffsets._month_to_int[suffix]
17421731
return cls(**kwargs)
17431732

17441733
@property
17451734
def rule_code(self):
1746-
month = _int_to_month[self.month]
1735+
month = liboffsets._int_to_month[self.month]
17471736
return '{prefix}-{month}'.format(prefix=self._prefix, month=month)
17481737

17491738

@@ -1784,7 +1773,8 @@ class YearBegin(BeginMixin, YearOffset):
17841773
@apply_index_wraps
17851774
def apply_index(self, i):
17861775
freq_month = 12 if self.month == 1 else self.month - 1
1787-
freqstr = 'A-{month}'.format(month=_int_to_month[freq_month])
1776+
month = liboffsets._int_to_month[freq_month]
1777+
freqstr = 'A-{month}'.format(month=month)
17881778
return self._beg_apply_index(i, freqstr)
17891779

17901780

@@ -1969,7 +1959,7 @@ def _get_suffix_prefix(self):
19691959

19701960
def get_rule_code_suffix(self):
19711961
prefix = self._get_suffix_prefix()
1972-
month = _int_to_month[self.startingMonth]
1962+
month = liboffsets._int_to_month[self.startingMonth]
19731963
weekday = _int_to_weekday[self.weekday]
19741964
return '{prefix}-{month}-{weekday}'.format(prefix=prefix, month=month,
19751965
weekday=weekday)
@@ -1984,7 +1974,7 @@ def _parse_suffix(cls, varion_code, startingMonth_code, weekday_code):
19841974
raise ValueError("Unable to parse varion_code: "
19851975
"{code}".format(code=varion_code))
19861976

1987-
startingMonth = _month_to_int[startingMonth_code]
1977+
startingMonth = liboffsets._month_to_int[startingMonth_code]
19881978
weekday = _weekday_to_int[weekday_code]
19891979

19901980
return {"weekday": weekday,

setup.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ class CheckSDist(sdist_class):
343343
'pandas/_libs/window.pyx',
344344
'pandas/_libs/sparse.pyx',
345345
'pandas/_libs/parsers.pyx',
346+
'pandas/_libs/tslibs/ccalendar.pyx',
346347
'pandas/_libs/tslibs/strptime.pyx',
347348
'pandas/_libs/tslibs/np_datetime.pyx',
348349
'pandas/_libs/tslibs/timedeltas.pyx',
@@ -558,6 +559,8 @@ def pxd(name):
558559
'_libs/tslibs/nattype'],
559560
'depends': tseries_depends,
560561
'sources': np_datetime_sources},
562+
'_libs.tslibs.ccalendar': {
563+
'pyxfile': '_libs/tslibs/ccalendar'},
561564
'_libs.tslibs.conversion': {
562565
'pyxfile': '_libs/tslibs/conversion',
563566
'pxdfiles': ['_libs/src/util',
@@ -584,6 +587,7 @@ def pxd(name):
584587
'_libs.tslibs.offsets': {
585588
'pyxfile': '_libs/tslibs/offsets',
586589
'pxdfiles': ['_libs/src/util',
590+
'_libs/tslibs/ccalendar',
587591
'_libs/tslibs/conversion',
588592
'_libs/tslibs/frequencies',
589593
'_libs/tslibs/nattype'],

0 commit comments

Comments
 (0)