Skip to content

Commit 1478845

Browse files
author
How Si Wei
committed
Update code and comments
1 parent 097a0fa commit 1478845

File tree

2 files changed

+114
-88
lines changed

2 files changed

+114
-88
lines changed

doc/source/whatsnew/v0.25.0.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ Other Enhancements
8282
- :meth:`DataFrame.query` and :meth:`DataFrame.eval` now supports quoting column names with backticks to refer to names with spaces (:issue:`6508`)
8383
- :func:`merge_asof` now gives a more clear error message when merge keys are categoricals that are not equal (:issue:`26136`)
8484
- :meth:`pandas.core.window.Rolling` supports exponential (or Poisson) window type (:issue:`21303`)
85-
- :class: `pandas.offsets.BusinessHour` supports multiple opening hours intervals
85+
- :class:`pandas.offsets.BusinessHour` supports multiple opening hours intervals (:issue:`15481`)
8686

8787
.. _whatsnew_0250.api_breaking:
8888

pandas/tseries/offsets.py

+113-87
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from pandas.util._decorators import Appender, Substitution, cache_readonly
1818

1919
from pandas.core.dtypes.generic import ABCPeriod
20-
from pandas.core.dtypes.inference import _iterable_not_string
20+
from pandas.core.dtypes.inference import is_list_like
2121

2222
from pandas.core.tools.datetimes import to_datetime
2323

@@ -580,22 +580,19 @@ class BusinessHourMixin(BusinessMixin):
580580

581581
def __init__(self, start='09:00', end='17:00', offset=timedelta(0)):
582582
# must be validated here to equality check
583-
if _iterable_not_string(start):
584-
start = np.asarray(start)
585-
if len(start) == 0:
586-
raise ValueError('Must include at least 1 start time')
587-
else:
588-
start = np.array([start])
589-
if _iterable_not_string(end):
590-
end = np.asarray(end)
591-
if len(end) == 0:
592-
raise ValueError('Must include at least 1 end time')
593-
else:
594-
end = np.array([end])
583+
if not is_list_like(start):
584+
start = [start]
585+
if not len(start):
586+
raise ValueError('Must include at least 1 start time')
587+
588+
if not is_list_like(end):
589+
end = [end]
590+
if not len(end):
591+
raise ValueError('Must include at least 1 end time')
595592

596593
vliboffsets = np.vectorize(liboffsets._validate_business_time)
597-
start = vliboffsets(start)
598-
end = vliboffsets(end)
594+
start = vliboffsets(np.asarray(start))
595+
end = vliboffsets(np.asarray(end))
599596

600597
# Validation of input
601598
if len(start) != len(end):
@@ -605,6 +602,7 @@ def __init__(self, start='09:00', end='17:00', offset=timedelta(0)):
605602

606603
# sort starting and ending time by starting time
607604
index = np.argsort(start)
605+
608606
# convert to tuple so that start and end are hashable
609607
start = tuple(start[index])
610608
end = tuple(end[index])
@@ -641,10 +639,7 @@ def next_bday(self):
641639
else:
642640
return BusinessDay(n=nb_offset)
643641

644-
def _get_daytime_flag(self, start, end):
645-
return start < end
646-
647-
def _next_opening_time(self, other, sign=1):
642+
def _next_opening_time(self, other: datetime, sign: int=1) -> datetime:
648643
"""
649644
If self.n and sign have the same sign, return the earliest opening time
650645
later than or equal to current time.
@@ -653,68 +648,87 @@ def _next_opening_time(self, other, sign=1):
653648
654649
Opening time always locates on BusinessDay.
655650
However, closing time may not if business hour extends over midnight.
651+
652+
Parameters
653+
----------
654+
other : datetime
655+
Current time.
656+
sign : int, default 1.
657+
Either 1 or -1. Going forward in time if it has the same sign as
658+
self.n. Going backward in time otherwise.
659+
660+
Returns
661+
-------
662+
result : datetime
663+
Next opening time.
656664
"""
657665
earliest_start = self.start[0]
658666
latest_start = self.start[-1]
667+
659668
if not self.next_bday.onOffset(other):
660669
# today is not business day
661670
other = other + sign * self.next_bday
662671
if self.n * sign >= 0:
663-
return datetime(other.year, other.month, other.day,
664-
earliest_start.hour, earliest_start.minute)
672+
hour, minute = earliest_start.hour, earliest_start.minute
665673
else:
666-
return datetime(other.year, other.month, other.day,
667-
latest_start.hour, latest_start.minute)
668-
else:
669-
if self.n * sign >= 0 and latest_start < other.time():
670-
# current time is after latest starting time in today
671-
other = other + sign * self.next_bday
672-
return datetime(other.year, other.month, other.day,
673-
earliest_start.hour, earliest_start.minute)
674-
elif self.n * sign < 0 and other.time() < earliest_start:
675-
# current time is before earliest starting time in today
676-
other = other + sign * self.next_bday
677-
return datetime(other.year, other.month, other.day,
678-
latest_start.hour, latest_start.minute)
679-
if self.n * sign >= 0:
680-
# find earliest starting time later than or equal to current time
681-
for st in self.start:
682-
if other.time() <= st:
683-
return datetime(other.year, other.month, other.day,
684-
st.hour, st.minute)
674+
hour, minute = latest_start.hour, latest_start.minute
685675
else:
686-
# find latest starting time earlier than or equal to current time
687-
for st in reversed(self.start):
688-
if other.time() >= st:
689-
return datetime(other.year, other.month, other.day,
690-
st.hour, st.minute)
676+
if self.n * sign >= 0:
677+
if latest_start < other.time():
678+
# current time is after latest starting time in today
679+
other = other + sign * self.next_bday
680+
hour, minute = earliest_start.hour, earliest_start.minute
681+
else:
682+
# find earliest starting time later than or equal to current time
683+
for st in self.start:
684+
if other.time() <= st:
685+
hour, minute = st.hour, st.minute
686+
break
687+
else:
688+
if other.time() < earliest_start:
689+
# current time is before earliest starting time in today
690+
other = other + sign * self.next_bday
691+
hour, minute = latest_start.hour, latest_start.minute
692+
else:
693+
# find latest starting time earlier than or equal to current time
694+
for st in reversed(self.start):
695+
if other.time() >= st:
696+
hour, minute = st.hour, st.minute
697+
break
698+
699+
return datetime(other.year, other.month, other.day, hour, minute)
691700

692-
def _prev_opening_time(self, other):
701+
def _prev_opening_time(self, other: datetime) -> datetime:
693702
"""
694703
If n is positive, return the latest opening time earlier than or equal
695704
to current time.
696705
Otherwise the earliest opening time later than or equal to current
697706
time.
698707
708+
Parameters
709+
----------
710+
other : datetime
711+
Current time.
712+
713+
Returns
714+
-------
715+
result : datetime
716+
Previous opening time.
699717
"""
700718
return self._next_opening_time(other, sign=-1)
701719

702-
def _get_business_hours_by_sec(self, start, end):
720+
def _get_business_hours_by_sec(self, start: datetime, end: datetime) -> int:
703721
"""
704722
Return business hours in a day by seconds.
705723
"""
706-
if self._get_daytime_flag(start, end):
707-
# create dummy datetime to calculate businesshours in a day
708-
dtstart = datetime(2014, 4, 1, start.hour, start.minute)
709-
until = datetime(2014, 4, 1, end.hour, end.minute)
710-
return (until - dtstart).total_seconds()
711-
else:
712-
dtstart = datetime(2014, 4, 1, start.hour, start.minute)
713-
until = datetime(2014, 4, 2, end.hour, end.minute)
714-
return (until - dtstart).total_seconds()
724+
# create dummy datetime to calculate businesshours in a day
725+
dtstart = datetime(2014, 4, 1, start.hour, start.minute)
726+
day = 1 if start < end else 2
727+
until = datetime(2014, 4, day, end.hour, end.minute)
728+
return (until - dtstart).total_seconds()
715729

716730
@apply_wraps
717-
def rollback(self, dt):
731+
def rollback(self, dt: datetime) -> datetime:
718732
"""
719733
Roll provided date backward to next offset only if not on offset.
720734
"""
@@ -727,7 +741,7 @@ def rollback(self, dt):
727741
return dt
728742

729743
@apply_wraps
730-
def rollforward(self, dt):
744+
def rollforward(self, dt: datetime) -> datetime:
731745
"""
732746
Roll provided date forward to next offset only if not on offset.
733747
"""
@@ -738,8 +752,20 @@ def rollforward(self, dt):
738752
return self._prev_opening_time(dt)
739753
return dt
740754

741-
def _get_closing_time(self, dt):
742-
# dt is guaranteed to be a starting time
755+
def _get_closing_time(self, dt: datetime) -> datetime:
756+
"""
757+
Get the closing time of a business hour interval by its opening time.
758+
759+
Parameters
760+
----------
761+
dt : datetime
762+
Opening time of a business hour interval.
763+
764+
Returns
765+
-------
766+
result : datetime
767+
Corresponding closing time.
768+
"""
743769
for i, st in enumerate(self.start):
744770
if st.hour == dt.hour and st.minute == dt.minute:
745771
return dt + timedelta(
@@ -756,6 +782,8 @@ def apply(self, other):
756782
other.hour, other.minute,
757783
other.second, other.microsecond)
758784
n = self.n
785+
786+
# adjust other to reduce cases to handle
759787
if n >= 0:
760788
if (other.time() in self.end or
761789
not self._onOffset(other)):
@@ -768,12 +796,15 @@ def apply(self, other):
768796
other = self._next_opening_time(other)
769797
other = self._get_closing_time(other)
770798

799+
# get total business hours by sec in one business day
771800
businesshours = sum(self._get_business_hours_by_sec(st, en)
772801
for st, en in zip(self.start, self.end))
802+
773803
bd, r = divmod(abs(n * 60), businesshours // 60)
774804
if n < 0:
775805
bd, r = -bd, -r
776806

807+
# adjust by business days first
777808
if bd != 0:
778809
skip_bd = BusinessDay(n=bd)
779810
# midnight business hour may not on BusinessDay
@@ -784,41 +815,36 @@ def apply(self, other):
784815
else:
785816
other = other + skip_bd
786817

787-
hours, minutes = divmod(r, 60)
788-
rem = timedelta(hours=hours, minutes=minutes)
818+
# remaining business hours to adjust
819+
bhour_remain = timedelta(minutes=r)
789820

790-
# because of previous adjustment, time will be larger than start
791821
if n >= 0:
792-
while rem != timedelta(0):
793-
bhour_left = self._get_closing_time(
822+
while bhour_remain != timedelta(0):
823+
# business hour left in this business time interval
824+
bhour = self._get_closing_time(
794825
self._prev_opening_time(other)) - other
795-
if bhour_left >= rem:
796-
other = other + rem
797-
rem = timedelta(0)
826+
if bhour_remain < bhour:
827+
# finish adjusting if possible
828+
other += bhour_remain
829+
bhour_remain = timedelta(0)
798830
else:
799-
rem = rem - bhour_left
800-
other = self._next_opening_time(other + bhour_left)
831+
# go to next business time interval
832+
bhour_remain -= bhour
833+
other = self._next_opening_time(other + bhour)
801834
else:
802-
while rem != timedelta(0):
803-
bhour_left = self._next_opening_time(other) - other
804-
if bhour_left <= rem:
805-
other = other + rem
806-
rem = timedelta(0)
835+
while bhour_remain != timedelta(0):
836+
# business hour left in this business time interval
837+
bhour = self._next_opening_time(other) - other
838+
if bhour_remain > bhour or bhour_remain == bhour and nanosecond != 0:
839+
# finish adjusting if possible
840+
other += bhour_remain
841+
bhour_remain = timedelta(0)
807842
else:
808-
rem = rem - bhour_left
843+
# go to next business time interval
844+
bhour_remain -= bhour
809845
other = self._get_closing_time(
810846
self._next_opening_time(
811-
other + bhour_left - timedelta(seconds=1)))
812-
813-
# edge handling
814-
if n >= 0:
815-
if other.time() in self.end:
816-
other = self._next_opening_time(other)
817-
else:
818-
if other.time() in self.start and nanosecond == 0:
819-
# adjustment to move to previous business day
820-
other = self._get_closing_time(self._next_opening_time(
821-
other - timedelta(seconds=1)))
847+
other + bhour - timedelta(seconds=1)))
822848

823849
return other
824850
else:

0 commit comments

Comments
 (0)