Skip to content

Commit 913eee2

Browse files
author
Si Wei How
committed
Support multiple starting and ending times in BusinessHour
1 parent 3b24fb6 commit 913eee2

File tree

1 file changed

+134
-72
lines changed

1 file changed

+134
-72
lines changed

pandas/tseries/offsets.py

Lines changed: 134 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -579,9 +579,28 @@ class BusinessHourMixin(BusinessMixin):
579579

580580
def __init__(self, start='09:00', end='17:00', offset=timedelta(0)):
581581
# must be validated here to equality check
582-
start = liboffsets._validate_business_time(start)
582+
if not isinstance(start, (tuple, list)):
583+
start = (start,)
584+
if not isinstance(end, (tuple, list)):
585+
end = (end,)
586+
start = tuple(map(liboffsets._validate_business_time, start))
587+
end = tuple(map(liboffsets._validate_business_time, end))
588+
589+
# Validation of input
590+
if len(start) != len(end):
591+
raise ValueError('number of starting time and ending time must be the same')
592+
num_openings = len(start)
593+
openings = sorted(zip(start, end))
594+
start = tuple(x for x, _ in openings)
595+
end = tuple(x for _, x in openings)
596+
total_secs = 0
597+
for i in range(num_openings):
598+
total_secs += self._get_business_hours_by_sec(start[i], end[i])
599+
total_secs += self._get_business_hours_by_sec(end[i], start[(i + 1) % num_openings])
600+
if total_secs != 24 * 60 * 60:
601+
raise ValueError('invalid starting and ending time(s)')
602+
583603
object.__setattr__(self, "start", start)
584-
end = liboffsets._validate_business_time(end)
585604
object.__setattr__(self, "end", end)
586605
object.__setattr__(self, "_offset", offset)
587606

@@ -603,14 +622,8 @@ def next_bday(self):
603622
else:
604623
return BusinessDay(n=nb_offset)
605624

606-
@cache_readonly
607-
def _get_daytime_flag(self):
608-
if self.start == self.end:
609-
raise ValueError('start and end must not be the same')
610-
elif self.start < self.end:
611-
return True
612-
else:
613-
return False
625+
def _get_daytime_flag(self, start, end):
626+
return start < end
614627

615628
def _next_opening_time(self, other):
616629
"""
@@ -620,44 +633,83 @@ def _next_opening_time(self, other):
620633
Opening time always locates on BusinessDay.
621634
Otherwise, closing time may not if business hour extends over midnight.
622635
"""
636+
earliest_start = self.start[0]
637+
latest_start = self.start[-1]
623638
if not self.next_bday.onOffset(other):
624639
other = other + self.next_bday
640+
if self.n >= 0:
641+
return datetime(other.year, other.month, other.day,
642+
earliest_start.hour, earliest_start.minute)
643+
else:
644+
return datetime(other.year, other.month, other.day,
645+
latest_start.hour, latest_start.minute)
625646
else:
626-
if self.n >= 0 and self.start < other.time():
647+
if self.n >= 0 and latest_start < other.time():
627648
other = other + self.next_bday
628-
elif self.n < 0 and other.time() < self.start:
649+
return datetime(other.year, other.month, other.day,
650+
earliest_start.hour, earliest_start.minute)
651+
elif self.n < 0 and other.time() < earliest_start:
629652
other = other + self.next_bday
630-
return datetime(other.year, other.month, other.day,
631-
self.start.hour, self.start.minute)
653+
return datetime(other.year, other.month, other.day,
654+
latest_start.hour, latest_start.minute)
655+
if self.n >= 0:
656+
for st in self.start:
657+
if other.time() <= st:
658+
return datetime(other.year, other.month, other.day,
659+
st.hour, st.minute)
660+
else:
661+
for st in reversed(self.start):
662+
if other.time() >= st:
663+
return datetime(other.year, other.month, other.day,
664+
st.hour, st.minute)
632665

633666
def _prev_opening_time(self, other):
634667
"""
635668
If n is positive, return yesterday's business day opening time.
636669
Otherwise yesterday business day's opening time.
637670
"""
671+
earliest_start = self.start[0]
672+
latest_start = self.start[-1]
638673
if not self.next_bday.onOffset(other):
639674
other = other - self.next_bday
675+
if self.n < 0:
676+
return datetime(other.year, other.month, other.day,
677+
earliest_start.hour, earliest_start.minute)
678+
else:
679+
return datetime(other.year, other.month, other.day,
680+
latest_start.hour, latest_start.minute)
640681
else:
641-
if self.n >= 0 and other.time() < self.start:
682+
if self.n < 0 and latest_start < other.time():
642683
other = other - self.next_bday
643-
elif self.n < 0 and other.time() > self.start:
684+
return datetime(other.year, other.month, other.day,
685+
earliest_start.hour, earliest_start.minute)
686+
elif self.n >= 0 and other.time() < earliest_start:
644687
other = other - self.next_bday
645-
return datetime(other.year, other.month, other.day,
646-
self.start.hour, self.start.minute)
688+
return datetime(other.year, other.month, other.day,
689+
latest_start.hour, latest_start.minute)
690+
if self.n < 0:
691+
for st in self.start:
692+
if other.time() <= st:
693+
return datetime(other.year, other.month, other.day,
694+
st.hour, st.minute)
695+
else:
696+
for st in reversed(self.start):
697+
if other.time() >= st:
698+
return datetime(other.year, other.month, other.day,
699+
st.hour, st.minute)
647700

648-
@cache_readonly
649-
def _get_business_hours_by_sec(self):
701+
def _get_business_hours_by_sec(self, start, end):
650702
"""
651703
Return business hours in a day by seconds.
652704
"""
653-
if self._get_daytime_flag:
705+
if self._get_daytime_flag(start, end):
654706
# create dummy datetime to calculate businesshours in a day
655-
dtstart = datetime(2014, 4, 1, self.start.hour, self.start.minute)
656-
until = datetime(2014, 4, 1, self.end.hour, self.end.minute)
707+
dtstart = datetime(2014, 4, 1, start.hour, start.minute)
708+
until = datetime(2014, 4, 1, end.hour, end.minute)
657709
return (until - dtstart).total_seconds()
658710
else:
659-
dtstart = datetime(2014, 4, 1, self.start.hour, self.start.minute)
660-
until = datetime(2014, 4, 2, self.end.hour, self.end.minute)
711+
dtstart = datetime(2014, 4, 1, start.hour, start.minute)
712+
until = datetime(2014, 4, 2, end.hour, end.minute)
661713
return (until - dtstart).total_seconds()
662714

663715
@apply_wraps
@@ -666,13 +718,11 @@ def rollback(self, dt):
666718
Roll provided date backward to next offset only if not on offset.
667719
"""
668720
if not self.onOffset(dt):
669-
businesshours = self._get_business_hours_by_sec
670721
if self.n >= 0:
671-
dt = self._prev_opening_time(
672-
dt) + timedelta(seconds=businesshours)
722+
dt = self._prev_opening_time(dt)
673723
else:
674-
dt = self._next_opening_time(
675-
dt) + timedelta(seconds=businesshours)
724+
dt = self._next_opening_time(dt)
725+
return self._get_closing_time(dt)
676726
return dt
677727

678728
@apply_wraps
@@ -687,33 +737,37 @@ def rollforward(self, dt):
687737
return self._prev_opening_time(dt)
688738
return dt
689739

740+
def _get_closing_time(self, dt):
741+
for i, st in enumerate(self.start):
742+
if st.hour == dt.hour and st.minute == dt.minute:
743+
return dt + timedelta(seconds=self._get_business_hours_by_sec(st, self.end[i]))
744+
raise ValueError("dt should be a starting time")
745+
690746
@apply_wraps
691747
def apply(self, other):
692-
daytime = self._get_daytime_flag
693-
businesshours = self._get_business_hours_by_sec
694-
bhdelta = timedelta(seconds=businesshours)
695-
696748
if isinstance(other, datetime):
697749
# used for detecting edge condition
698750
nanosecond = getattr(other, 'nanosecond', 0)
699751
# reset timezone and nanosecond
700752
# other may be a Timestamp, thus not use replace
701753
other = datetime(other.year, other.month, other.day,
702-
other.hour, other.minute,
703-
other.second, other.microsecond)
754+
other.hour, other.minute,
755+
other.second, other.microsecond)
704756
n = self.n
705757
if n >= 0:
706-
if (other.time() == self.end or
707-
not self._onOffset(other, businesshours)):
758+
if (other.time() in self.end or
759+
not self._onOffset(other)):
708760
other = self._next_opening_time(other)
709761
else:
710-
if other.time() == self.start:
762+
if other.time() in self.start:
711763
# adjustment to move to previous business day
712764
other = other - timedelta(seconds=1)
713-
if not self._onOffset(other, businesshours):
765+
if not self._onOffset(other):
714766
other = self._next_opening_time(other)
715-
other = other + bhdelta
767+
other = self._get_closing_time(other)
716768

769+
businesshours = sum(self._get_business_hours_by_sec(st, en)
770+
for st, en in zip(self.start, self.end))
717771
bd, r = divmod(abs(n * 60), businesshours // 60)
718772
if n < 0:
719773
bd, r = -bd, -r
@@ -722,45 +776,50 @@ def apply(self, other):
722776
skip_bd = BusinessDay(n=bd)
723777
# midnight business hour may not on BusinessDay
724778
if not self.next_bday.onOffset(other):
725-
remain = other - self._prev_opening_time(other)
726-
other = self._next_opening_time(other + skip_bd) + remain
779+
prev_open = self._prev_opening_time(other)
780+
remain = other - prev_open
781+
other = prev_open + skip_bd + remain
727782
else:
728783
other = other + skip_bd
729784

730785
hours, minutes = divmod(r, 60)
731-
result = other + timedelta(hours=hours, minutes=minutes)
786+
rem = timedelta(hours=hours, minutes=minutes)
732787

733788
# because of previous adjustment, time will be larger than start
734-
if ((daytime and (result.time() < self.start or
735-
self.end < result.time())) or
736-
not daytime and (self.end < result.time() < self.start)):
737-
if n >= 0:
738-
bday_edge = self._prev_opening_time(other)
739-
bday_edge = bday_edge + bhdelta
740-
# calculate remainder
741-
bday_remain = result - bday_edge
742-
result = self._next_opening_time(other)
743-
result += bday_remain
744-
else:
745-
bday_edge = self._next_opening_time(other)
746-
bday_remain = result - bday_edge
747-
result = self._next_opening_time(result) + bhdelta
748-
result += bday_remain
789+
if n >= 0:
790+
while rem != timedelta(0):
791+
bhour_left = self._get_closing_time(self._prev_opening_time(other)) - other
792+
if bhour_left >= rem:
793+
other = other + rem
794+
rem = timedelta(0)
795+
else:
796+
rem = rem - bhour_left
797+
other = self._next_opening_time(other + bhour_left)
798+
else:
799+
while rem != timedelta(0):
800+
bhour_left = self._next_opening_time(other) - other
801+
if bhour_left <= rem:
802+
other = other + rem
803+
rem = timedelta(0)
804+
else:
805+
rem = rem - bhour_left
806+
other = self._get_closing_time(
807+
self._next_opening_time(other + bhour_left - timedelta(seconds=1)))
808+
749809
# edge handling
750810
if n >= 0:
751-
if result.time() == self.end:
752-
result = self._next_opening_time(result)
811+
if other.time() in self.end:
812+
other = self._next_opening_time(other)
753813
else:
754-
if result.time() == self.start and nanosecond == 0:
814+
if other.time() in self.start and nanosecond == 0:
755815
# adjustment to move to previous business day
756-
result = self._next_opening_time(
757-
result - timedelta(seconds=1)) + bhdelta
816+
other = self._get_closing_time(self._next_opening_time(
817+
other - timedelta(seconds=1)))
758818

759-
return result
819+
return other
760820
else:
761-
# TODO: Figure out the end of this sente
762821
raise ApplyTypeError(
763-
'Only know how to combine business hour with ')
822+
'Only know how to combine business hour with datetime')
764823

765824
def onOffset(self, dt):
766825
if self.normalize and not _is_normalized(dt):
@@ -771,10 +830,9 @@ def onOffset(self, dt):
771830
dt.minute, dt.second, dt.microsecond)
772831
# Valid BH can be on the different BusinessDay during midnight
773832
# Distinguish by the time spent from previous opening time
774-
businesshours = self._get_business_hours_by_sec
775-
return self._onOffset(dt, businesshours)
833+
return self._onOffset(dt)
776834

777-
def _onOffset(self, dt, businesshours):
835+
def _onOffset(self, dt):
778836
"""
779837
Slight speedups using calculated values.
780838
"""
@@ -787,15 +845,19 @@ def _onOffset(self, dt, businesshours):
787845
else:
788846
op = self._next_opening_time(dt)
789847
span = (dt - op).total_seconds()
848+
businesshours = 0
849+
for i, st in enumerate(self.start):
850+
if op.hour == st.hour and op.minute == st.minute:
851+
businesshours = self._get_business_hours_by_sec(st, self.end[i])
790852
if span <= businesshours:
791853
return True
792854
else:
793855
return False
794856

795857
def _repr_attrs(self):
796858
out = super()._repr_attrs()
797-
start = self.start.strftime('%H:%M')
798-
end = self.end.strftime('%H:%M')
859+
start = ','.join(st.strftime('%H:%M') for st in self.start)
860+
end = ','.join(en.strftime('%H:%M') for en in self.end)
799861
attrs = ['{prefix}={start}-{end}'.format(prefix=self._prefix,
800862
start=start, end=end)]
801863
out += ': ' + ', '.join(attrs)

0 commit comments

Comments
 (0)