|
4 | 4 | import numpy as np
|
5 | 5 |
|
6 | 6 | from pandas.tseries.tools import to_datetime, normalize_date
|
7 |
| -from pandas.core.common import ABCSeries, ABCDatetimeIndex, ABCPeriod |
| 7 | +from pandas.core.common import (ABCSeries, ABCDatetimeIndex, ABCPeriod, |
| 8 | + AbstractMethodError) |
8 | 9 |
|
9 | 10 | # import after tools, dateutil check
|
10 | 11 | from dateutil.relativedelta import relativedelta, weekday
|
|
18 | 19 | __all__ = ['Day', 'BusinessDay', 'BDay', 'CustomBusinessDay', 'CDay',
|
19 | 20 | 'CBMonthEnd', 'CBMonthBegin',
|
20 | 21 | 'MonthBegin', 'BMonthBegin', 'MonthEnd', 'BMonthEnd',
|
| 22 | + 'SemiMonthEnd', 'SemiMonthBegin', |
21 | 23 | 'BusinessHour', 'CustomBusinessHour',
|
22 | 24 | 'YearBegin', 'BYearBegin', 'YearEnd', 'BYearEnd',
|
23 | 25 | 'QuarterBegin', 'BQuarterBegin', 'QuarterEnd', 'BQuarterEnd',
|
@@ -1160,6 +1162,214 @@ def onOffset(self, dt):
|
1160 | 1162 | _prefix = 'MS'
|
1161 | 1163 |
|
1162 | 1164 |
|
| 1165 | +class SemiMonthOffset(DateOffset): |
| 1166 | + _adjust_dst = True |
| 1167 | + _default_day_of_month = 15 |
| 1168 | + _min_day_of_month = 2 |
| 1169 | + |
| 1170 | + def __init__(self, n=1, day_of_month=None, normalize=False, **kwds): |
| 1171 | + if day_of_month is None: |
| 1172 | + self.day_of_month = self._default_day_of_month |
| 1173 | + else: |
| 1174 | + self.day_of_month = int(day_of_month) |
| 1175 | + if not self._min_day_of_month <= self.day_of_month <= 27: |
| 1176 | + raise ValueError('day_of_month must be ' |
| 1177 | + '{}<=day_of_month<=27, got {}'.format( |
| 1178 | + self._min_day_of_month, self.day_of_month)) |
| 1179 | + self.n = int(n) |
| 1180 | + self.normalize = normalize |
| 1181 | + self.kwds = kwds |
| 1182 | + self.kwds['day_of_month'] = self.day_of_month |
| 1183 | + |
| 1184 | + @classmethod |
| 1185 | + def _from_name(cls, suffix=None): |
| 1186 | + return cls(day_of_month=suffix) |
| 1187 | + |
| 1188 | + @property |
| 1189 | + def rule_code(self): |
| 1190 | + suffix = '-{}'.format(self.day_of_month) |
| 1191 | + return self._prefix + suffix |
| 1192 | + |
| 1193 | + @apply_wraps |
| 1194 | + def apply(self, other): |
| 1195 | + n = self.n |
| 1196 | + if not self.onOffset(other): |
| 1197 | + _, days_in_month = tslib.monthrange(other.year, other.month) |
| 1198 | + if 1 < other.day < self.day_of_month: |
| 1199 | + other += relativedelta(day=self.day_of_month) |
| 1200 | + if n > 0: |
| 1201 | + # rollforward so subtract 1 |
| 1202 | + n -= 1 |
| 1203 | + elif self.day_of_month < other.day < days_in_month: |
| 1204 | + other += relativedelta(day=self.day_of_month) |
| 1205 | + if n < 0: |
| 1206 | + # rollforward in the negative direction so add 1 |
| 1207 | + n += 1 |
| 1208 | + elif n == 0: |
| 1209 | + n = 1 |
| 1210 | + |
| 1211 | + return self._apply(n, other) |
| 1212 | + |
| 1213 | + def _apply(self, n, other): |
| 1214 | + """Handle specific apply logic for child classes""" |
| 1215 | + raise AbstractMethodError(self) |
| 1216 | + |
| 1217 | + @apply_index_wraps |
| 1218 | + def apply_index(self, i): |
| 1219 | + # determine how many days away from the 1st of the month we are |
| 1220 | + days_from_start = i.to_perioddelta('M').asi8 |
| 1221 | + delta = Timedelta(days=self.day_of_month - 1).value |
| 1222 | + |
| 1223 | + # get boolean array for each element before the day_of_month |
| 1224 | + before_day_of_month = days_from_start < delta |
| 1225 | + |
| 1226 | + # get boolean array for each element after the day_of_month |
| 1227 | + after_day_of_month = days_from_start > delta |
| 1228 | + |
| 1229 | + # determine the correct n for each date in i |
| 1230 | + roll = self._get_roll(i, before_day_of_month, after_day_of_month) |
| 1231 | + |
| 1232 | + # isolate the time since it will be striped away one the next line |
| 1233 | + time = i.to_perioddelta('D') |
| 1234 | + |
| 1235 | + # apply the correct number of months |
| 1236 | + i = (i.to_period('M') + (roll // 2)).to_timestamp() |
| 1237 | + |
| 1238 | + # apply the correct day |
| 1239 | + i = self._apply_index_days(i, roll) |
| 1240 | + |
| 1241 | + return i + time |
| 1242 | + |
| 1243 | + def _get_roll(self, i, before_day_of_month, after_day_of_month): |
| 1244 | + """Return an array with the correct n for each date in i. |
| 1245 | +
|
| 1246 | + The roll array is based on the fact that i gets rolled back to |
| 1247 | + the first day of the month. |
| 1248 | + """ |
| 1249 | + raise AbstractMethodError(self) |
| 1250 | + |
| 1251 | + def _apply_index_days(self, i, roll): |
| 1252 | + """Apply the correct day for each date in i""" |
| 1253 | + raise AbstractMethodError(self) |
| 1254 | + |
| 1255 | + |
| 1256 | +class SemiMonthEnd(SemiMonthOffset): |
| 1257 | + """ |
| 1258 | + Two DateOffset's per month repeating on the last |
| 1259 | + day of the month and day_of_month. |
| 1260 | +
|
| 1261 | + .. versionadded:: 0.18.2 |
| 1262 | +
|
| 1263 | + Parameters |
| 1264 | + ---------- |
| 1265 | + n: int |
| 1266 | + normalize : bool, default False |
| 1267 | + day_of_month: int, {1, 3,...,27}, default 15 |
| 1268 | + """ |
| 1269 | + _prefix = 'SM' |
| 1270 | + _min_day_of_month = 1 |
| 1271 | + |
| 1272 | + def onOffset(self, dt): |
| 1273 | + if self.normalize and not _is_normalized(dt): |
| 1274 | + return False |
| 1275 | + _, days_in_month = tslib.monthrange(dt.year, dt.month) |
| 1276 | + return dt.day in (self.day_of_month, days_in_month) |
| 1277 | + |
| 1278 | + def _apply(self, n, other): |
| 1279 | + # if other.day is not day_of_month move to day_of_month and update n |
| 1280 | + if other.day < self.day_of_month: |
| 1281 | + other += relativedelta(day=self.day_of_month) |
| 1282 | + if n > 0: |
| 1283 | + n -= 1 |
| 1284 | + elif other.day > self.day_of_month: |
| 1285 | + other += relativedelta(day=self.day_of_month) |
| 1286 | + if n == 0: |
| 1287 | + n = 1 |
| 1288 | + else: |
| 1289 | + n += 1 |
| 1290 | + |
| 1291 | + months = n // 2 |
| 1292 | + day = 31 if n % 2 else self.day_of_month |
| 1293 | + return other + relativedelta(months=months, day=day) |
| 1294 | + |
| 1295 | + def _get_roll(self, i, before_day_of_month, after_day_of_month): |
| 1296 | + n = self.n |
| 1297 | + is_month_end = i.is_month_end |
| 1298 | + if n > 0: |
| 1299 | + roll_end = np.where(is_month_end, 1, 0) |
| 1300 | + roll_before = np.where(before_day_of_month, n, n + 1) |
| 1301 | + roll = roll_end + roll_before |
| 1302 | + elif n == 0: |
| 1303 | + roll_after = np.where(after_day_of_month, 2, 0) |
| 1304 | + roll_before = np.where(~after_day_of_month, 1, 0) |
| 1305 | + roll = roll_before + roll_after |
| 1306 | + else: |
| 1307 | + roll = np.where(after_day_of_month, n + 2, n + 1) |
| 1308 | + return roll |
| 1309 | + |
| 1310 | + def _apply_index_days(self, i, roll): |
| 1311 | + i += (roll % 2) * Timedelta(days=self.day_of_month).value |
| 1312 | + return i + Timedelta(days=-1) |
| 1313 | + |
| 1314 | + |
| 1315 | +class SemiMonthBegin(SemiMonthOffset): |
| 1316 | + """ |
| 1317 | + Two DateOffset's per month repeating on the first |
| 1318 | + day of the month and day_of_month. |
| 1319 | +
|
| 1320 | + .. versionadded:: 0.18.2 |
| 1321 | +
|
| 1322 | + Parameters |
| 1323 | + ---------- |
| 1324 | + n: int |
| 1325 | + normalize : bool, default False |
| 1326 | + day_of_month: int, {2, 3,...,27}, default 15 |
| 1327 | + """ |
| 1328 | + _prefix = 'SMS' |
| 1329 | + |
| 1330 | + def onOffset(self, dt): |
| 1331 | + if self.normalize and not _is_normalized(dt): |
| 1332 | + return False |
| 1333 | + return dt.day in (1, self.day_of_month) |
| 1334 | + |
| 1335 | + def _apply(self, n, other): |
| 1336 | + # if other.day is not day_of_month move to day_of_month and update n |
| 1337 | + if other.day < self.day_of_month: |
| 1338 | + other += relativedelta(day=self.day_of_month) |
| 1339 | + if n == 0: |
| 1340 | + n = -1 |
| 1341 | + else: |
| 1342 | + n -= 1 |
| 1343 | + elif other.day > self.day_of_month: |
| 1344 | + other += relativedelta(day=self.day_of_month) |
| 1345 | + if n == 0: |
| 1346 | + n = 1 |
| 1347 | + elif n < 0: |
| 1348 | + n += 1 |
| 1349 | + |
| 1350 | + months = n // 2 + n % 2 |
| 1351 | + day = 1 if n % 2 else self.day_of_month |
| 1352 | + return other + relativedelta(months=months, day=day) |
| 1353 | + |
| 1354 | + def _get_roll(self, i, before_day_of_month, after_day_of_month): |
| 1355 | + n = self.n |
| 1356 | + is_month_start = i.is_month_start |
| 1357 | + if n > 0: |
| 1358 | + roll = np.where(before_day_of_month, n, n + 1) |
| 1359 | + elif n == 0: |
| 1360 | + roll_start = np.where(is_month_start, 0, 1) |
| 1361 | + roll_after = np.where(after_day_of_month, 1, 0) |
| 1362 | + roll = roll_start + roll_after |
| 1363 | + else: |
| 1364 | + roll_after = np.where(after_day_of_month, n + 2, n + 1) |
| 1365 | + roll_start = np.where(is_month_start, -1, 0) |
| 1366 | + roll = roll_after + roll_start |
| 1367 | + return roll |
| 1368 | + |
| 1369 | + def _apply_index_days(self, i, roll): |
| 1370 | + return i + (roll % 2) * Timedelta(days=self.day_of_month - 1).value |
| 1371 | + |
| 1372 | + |
1163 | 1373 | class BusinessMonthEnd(MonthOffset):
|
1164 | 1374 | """DateOffset increments between business EOM dates"""
|
1165 | 1375 |
|
@@ -2720,6 +2930,8 @@ def generate_range(start=None, end=None, periods=None,
|
2720 | 2930 | CustomBusinessHour, # 'CBH'
|
2721 | 2931 | MonthEnd, # 'M'
|
2722 | 2932 | MonthBegin, # 'MS'
|
| 2933 | + SemiMonthEnd, # 'SM' |
| 2934 | + SemiMonthBegin, # 'SMS' |
2723 | 2935 | Week, # 'W'
|
2724 | 2936 | Second, # 'S'
|
2725 | 2937 | Minute, # 'T'
|
|
0 commit comments