@@ -21,7 +21,7 @@ cnp.import_array()
2121from pandas._libs.tslibs cimport util
2222from pandas._libs.tslibs.util cimport is_integer_object
2323
24- from pandas._libs.tslibs.base cimport ABCTick, ABCTimestamp
24+ from pandas._libs.tslibs.base cimport ABCTick, ABCTimestamp, is_tick_object
2525
2626from pandas._libs.tslibs.ccalendar import MONTHS, DAYS
2727from pandas._libs.tslibs.ccalendar cimport get_days_in_month, dayofweek
@@ -93,10 +93,6 @@ cdef bint is_offset_object(object obj):
9393 return isinstance (obj, _BaseOffset)
9494
9595
96- cdef bint is_tick_object(object obj):
97- return isinstance (obj, _Tick)
98-
99-
10096cdef to_offset(object obj):
10197 """
10298 Wrap pandas.tseries.frequencies.to_offset to keep centralize runtime
@@ -335,7 +331,7 @@ def to_dt64D(dt):
335331# Validation
336332
337333
338- def validate_business_time (t_input ):
334+ def _validate_business_time (t_input ):
339335 if isinstance (t_input, str ):
340336 try :
341337 t = time.strptime(t_input, ' %H :%M ' )
@@ -528,6 +524,21 @@ class _BaseOffset:
528524 out = f' <{n_str}{className}{plural}{self._repr_attrs()}>'
529525 return out
530526
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+
531542 @property
532543 def name (self ) -> str:
533544 return self.rule_code
@@ -790,6 +801,97 @@ class BusinessMixin:
790801 return out
791802
792803
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+
793895class CustomMixin :
794896 """
795897 Mixin for classes that define and validate calendar, holidays,
@@ -809,6 +911,31 @@ class CustomMixin:
809911 object .__setattr__ (self , " calendar" , calendar)
810912
811913
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+
812939# ----------------------------------------------------------------------
813940# RelativeDelta Arithmetic
814941
0 commit comments