17
17
from pandas .util ._decorators import Appender , Substitution , cache_readonly
18
18
19
19
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
21
21
22
22
from pandas .core .tools .datetimes import to_datetime
23
23
@@ -580,22 +580,19 @@ class BusinessHourMixin(BusinessMixin):
580
580
581
581
def __init__ (self , start = '09:00' , end = '17:00' , offset = timedelta (0 )):
582
582
# 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' )
595
592
596
593
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 ) )
599
596
600
597
# Validation of input
601
598
if len (start ) != len (end ):
@@ -605,6 +602,7 @@ def __init__(self, start='09:00', end='17:00', offset=timedelta(0)):
605
602
606
603
# sort starting and ending time by starting time
607
604
index = np .argsort (start )
605
+
608
606
# convert to tuple so that start and end are hashable
609
607
start = tuple (start [index ])
610
608
end = tuple (end [index ])
@@ -641,10 +639,7 @@ def next_bday(self):
641
639
else :
642
640
return BusinessDay (n = nb_offset )
643
641
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 :
648
643
"""
649
644
If self.n and sign have the same sign, return the earliest opening time
650
645
later than or equal to current time.
@@ -653,68 +648,87 @@ def _next_opening_time(self, other, sign=1):
653
648
654
649
Opening time always locates on BusinessDay.
655
650
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.
656
664
"""
657
665
earliest_start = self .start [0 ]
658
666
latest_start = self .start [- 1 ]
667
+
659
668
if not self .next_bday .onOffset (other ):
660
669
# today is not business day
661
670
other = other + sign * self .next_bday
662
671
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
665
673
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
685
675
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 )
691
700
692
- def _prev_opening_time (self , other ) :
701
+ def _prev_opening_time (self , other : datetime ) -> datetime :
693
702
"""
694
703
If n is positive, return the latest opening time earlier than or equal
695
704
to current time.
696
705
Otherwise the earliest opening time later than or equal to current
697
706
time.
698
707
708
+ Parameters
709
+ ----------
710
+ other : datetime
711
+ Current time.
712
+
713
+ Returns
714
+ -------
715
+ result : datetime
716
+ Previous opening time.
699
717
"""
700
718
return self ._next_opening_time (other , sign = - 1 )
701
719
702
- def _get_business_hours_by_sec (self , start , end ) :
720
+ def _get_business_hours_by_sec (self , start : datetime , end : datetime ) -> int :
703
721
"""
704
722
Return business hours in a day by seconds.
705
723
"""
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 ()
715
729
716
730
@apply_wraps
717
- def rollback (self , dt ) :
731
+ def rollback (self , dt : datetime ) -> datetime :
718
732
"""
719
733
Roll provided date backward to next offset only if not on offset.
720
734
"""
@@ -727,7 +741,7 @@ def rollback(self, dt):
727
741
return dt
728
742
729
743
@apply_wraps
730
- def rollforward (self , dt ) :
744
+ def rollforward (self , dt : datetime ) -> datetime :
731
745
"""
732
746
Roll provided date forward to next offset only if not on offset.
733
747
"""
@@ -738,8 +752,20 @@ def rollforward(self, dt):
738
752
return self ._prev_opening_time (dt )
739
753
return dt
740
754
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
+ """
743
769
for i , st in enumerate (self .start ):
744
770
if st .hour == dt .hour and st .minute == dt .minute :
745
771
return dt + timedelta (
@@ -756,6 +782,8 @@ def apply(self, other):
756
782
other .hour , other .minute ,
757
783
other .second , other .microsecond )
758
784
n = self .n
785
+
786
+ # adjust other to reduce cases to handle
759
787
if n >= 0 :
760
788
if (other .time () in self .end or
761
789
not self ._onOffset (other )):
@@ -768,12 +796,15 @@ def apply(self, other):
768
796
other = self ._next_opening_time (other )
769
797
other = self ._get_closing_time (other )
770
798
799
+ # get total business hours by sec in one business day
771
800
businesshours = sum (self ._get_business_hours_by_sec (st , en )
772
801
for st , en in zip (self .start , self .end ))
802
+
773
803
bd , r = divmod (abs (n * 60 ), businesshours // 60 )
774
804
if n < 0 :
775
805
bd , r = - bd , - r
776
806
807
+ # adjust by business days first
777
808
if bd != 0 :
778
809
skip_bd = BusinessDay (n = bd )
779
810
# midnight business hour may not on BusinessDay
@@ -784,41 +815,36 @@ def apply(self, other):
784
815
else :
785
816
other = other + skip_bd
786
817
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 )
789
820
790
- # because of previous adjustment, time will be larger than start
791
821
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 (
794
825
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 )
798
830
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 )
801
834
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 )
807
842
else :
808
- rem = rem - bhour_left
843
+ # go to next business time interval
844
+ bhour_remain -= bhour
809
845
other = self ._get_closing_time (
810
846
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 )))
822
848
823
849
return other
824
850
else :
0 commit comments