@@ -21,7 +21,7 @@ cnp.import_array()
21
21
from pandas._libs.tslibs cimport util
22
22
from pandas._libs.tslibs.util cimport is_integer_object
23
23
24
- from pandas._libs.tslibs.base cimport ABCTick, ABCTimestamp
24
+ from pandas._libs.tslibs.base cimport ABCTick, ABCTimestamp, is_tick_object
25
25
26
26
from pandas._libs.tslibs.ccalendar import MONTHS, DAYS
27
27
from pandas._libs.tslibs.ccalendar cimport get_days_in_month, dayofweek
@@ -93,10 +93,6 @@ cdef bint is_offset_object(object obj):
93
93
return isinstance (obj, _BaseOffset)
94
94
95
95
96
- cdef bint is_tick_object(object obj):
97
- return isinstance (obj, _Tick)
98
-
99
-
100
96
cdef to_offset(object obj):
101
97
"""
102
98
Wrap pandas.tseries.frequencies.to_offset to keep centralize runtime
@@ -335,7 +331,7 @@ def to_dt64D(dt):
335
331
# Validation
336
332
337
333
338
- def validate_business_time (t_input ):
334
+ def _validate_business_time (t_input ):
339
335
if isinstance (t_input, str ):
340
336
try :
341
337
t = time.strptime(t_input, ' %H :%M ' )
@@ -528,6 +524,21 @@ class _BaseOffset:
528
524
out = f' <{n_str}{className}{plural}{self._repr_attrs()}>'
529
525
return out
530
526
527
+ def _repr_attrs(self ) -> str:
528
+ exclude = {" n" , " inc" , " normalize" }
529
+ attrs = []
530
+ for attr in sorted(self.__dict__ ):
531
+ if attr.startswith(" _" ) or attr == " kwds" :
532
+ continue
533
+ elif attr not in exclude:
534
+ value = getattr (self , attr)
535
+ attrs.append(f" {attr}={value}" )
536
+
537
+ out = " "
538
+ if attrs:
539
+ out += " : " + " , " .join(attrs)
540
+ return out
541
+
531
542
@property
532
543
def name (self ) -> str:
533
544
return self.rule_code
@@ -790,6 +801,97 @@ class BusinessMixin:
790
801
return out
791
802
792
803
804
+ class BusinessHourMixin(BusinessMixin ):
805
+ _adjust_dst = False
806
+
807
+ def __init__ (self , start = " 09:00" , end = " 17:00" , offset = timedelta(0 )):
808
+ # must be validated here to equality check
809
+ if np.ndim(start) == 0 :
810
+ # i.e. not is_list_like
811
+ start = [start]
812
+ if not len (start):
813
+ raise ValueError (" Must include at least 1 start time" )
814
+
815
+ if np.ndim(end) == 0 :
816
+ # i.e. not is_list_like
817
+ end = [end]
818
+ if not len (end):
819
+ raise ValueError (" Must include at least 1 end time" )
820
+
821
+ start = np.array([_validate_business_time(x) for x in start])
822
+ end = np.array([_validate_business_time(x) for x in end])
823
+
824
+ # Validation of input
825
+ if len (start) != len (end):
826
+ raise ValueError (" number of starting time and ending time must be the same" )
827
+ num_openings = len (start)
828
+
829
+ # sort starting and ending time by starting time
830
+ index = np.argsort(start)
831
+
832
+ # convert to tuple so that start and end are hashable
833
+ start = tuple (start[index])
834
+ end = tuple (end[index])
835
+
836
+ total_secs = 0
837
+ for i in range (num_openings):
838
+ total_secs += self ._get_business_hours_by_sec(start[i], end[i])
839
+ total_secs += self ._get_business_hours_by_sec(
840
+ end[i], start[(i + 1 ) % num_openings]
841
+ )
842
+ if total_secs != 24 * 60 * 60 :
843
+ raise ValueError (
844
+ " invalid starting and ending time(s): "
845
+ " opening hours should not touch or overlap with "
846
+ " one another"
847
+ )
848
+
849
+ object .__setattr__ (self , " start" , start)
850
+ object .__setattr__ (self , " end" , end)
851
+ object .__setattr__ (self , " _offset" , offset)
852
+
853
+ def _repr_attrs (self ) -> str:
854
+ out = super ()._repr_attrs()
855
+ hours = " ," .join(
856
+ f' {st.strftime("%H :%M ")}-{en.strftime("%H :%M ")}'
857
+ for st, en in zip (self .start, self .end)
858
+ )
859
+ attrs = [f" {self._prefix}={hours}" ]
860
+ out += ": " + ", ".join(attrs )
861
+ return out
862
+
863
+ def _get_business_hours_by_sec(self , start , end ):
864
+ """
865
+ Return business hours in a day by seconds.
866
+ """
867
+ # create dummy datetime to calculate business hours in a day
868
+ dtstart = datetime(2014 , 4 , 1 , start.hour, start.minute)
869
+ day = 1 if start < end else 2
870
+ until = datetime(2014 , 4 , day, end.hour, end.minute)
871
+ return int ((until - dtstart).total_seconds())
872
+
873
+ def _get_closing_time (self , dt ):
874
+ """
875
+ Get the closing time of a business hour interval by its opening time.
876
+
877
+ Parameters
878
+ ----------
879
+ dt : datetime
880
+ Opening time of a business hour interval.
881
+
882
+ Returns
883
+ -------
884
+ result : datetime
885
+ Corresponding closing time.
886
+ """
887
+ for i, st in enumerate (self .start):
888
+ if st.hour == dt.hour and st.minute == dt.minute:
889
+ return dt + timedelta(
890
+ seconds = self ._get_business_hours_by_sec(st, self .end[i])
891
+ )
892
+ assert False
893
+
894
+
793
895
class CustomMixin :
794
896
"""
795
897
Mixin for classes that define and validate calendar, holidays,
@@ -809,6 +911,31 @@ class CustomMixin:
809
911
object .__setattr__ (self , " calendar" , calendar)
810
912
811
913
914
+ class WeekOfMonthMixin :
915
+ """
916
+ Mixin for methods common to WeekOfMonth and LastWeekOfMonth.
917
+ """
918
+
919
+ @apply_wraps
920
+ def apply (self , other ):
921
+ compare_day = self ._get_offset_day(other)
922
+
923
+ months = self .n
924
+ if months > 0 and compare_day > other.day:
925
+ months -= 1
926
+ elif months <= 0 and compare_day < other.day:
927
+ months += 1
928
+
929
+ shifted = shift_month(other, months, " start" )
930
+ to_day = self ._get_offset_day(shifted)
931
+ return shift_day(shifted, to_day - shifted.day)
932
+
933
+ def is_on_offset (self , dt ) -> bool:
934
+ if self.normalize and not is_normalized(dt ):
935
+ return False
936
+ return dt.day == self ._get_offset_day(dt)
937
+
938
+
812
939
# ----------------------------------------------------------------------
813
940
# RelativeDelta Arithmetic
814
941
0 commit comments