Skip to content

Commit 309843a

Browse files
committed
ENH: Add BusinessHour offset
1 parent e331a87 commit 309843a

File tree

7 files changed

+1032
-15
lines changed

7 files changed

+1032
-15
lines changed

doc/source/timeseries.rst

+100-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
.. ipython:: python
55
:suppress:
66
7-
from datetime import datetime, timedelta
7+
from datetime import datetime, timedelta, time
88
import numpy as np
99
np.random.seed(123456)
1010
from pandas import *
@@ -482,11 +482,13 @@ frequency increment. Specific offset logic like "month", "business day", or
482482
BYearEnd, "business year end"
483483
BYearBegin, "business year begin"
484484
FY5253, "retail (aka 52-53 week) year"
485+
BusinessHour, "business hour"
485486
Hour, "one hour"
486487
Minute, "one minute"
487488
Second, "one second"
488489
Milli, "one millisecond"
489490
Micro, "one microsecond"
491+
Nano, "one nanosecond"
490492

491493

492494
The basic ``DateOffset`` takes the same arguments as
@@ -667,6 +669,102 @@ in the usual way.
667669
have to change to fix the timezone issues, the behaviour of the
668670
``CustomBusinessDay`` class may have to change in future versions.
669671

672+
.. _timeseries.businesshour:
673+
674+
Business Hour
675+
~~~~~~~~~~~~~
676+
677+
The ``BusinessHour`` class provides a business hour representation on ``BusinessDay``,
678+
allowing to use specific start and end times.
679+
680+
By default, ``BusinessHour`` uses 9:00 - 17:00 as business hours.
681+
Adding ``BusinessHour`` will increment ``Timestamp`` by hourly.
682+
If target ``Timestamp`` is out of business hours, move to the next business hour then increment it.
683+
If the result exceeds the business hours end, remaining is added to the next business day.
684+
685+
.. ipython:: python
686+
687+
bh = BusinessHour()
688+
bh
689+
690+
# 2014-08-01 is Friday
691+
Timestamp('2014-08-01 10:00').weekday()
692+
Timestamp('2014-08-01 10:00') + bh
693+
694+
# Below example is the same as Timestamp('2014-08-01 09:00') + bh
695+
Timestamp('2014-08-01 08:00') + bh
696+
697+
# If the results is on the end time, move to the next business day
698+
Timestamp('2014-08-01 16:00') + bh
699+
700+
# Remainings are added to the next day
701+
Timestamp('2014-08-01 16:30') + bh
702+
703+
# Adding 2 business hours
704+
Timestamp('2014-08-01 10:00') + BusinessHour(2)
705+
706+
# Subtracting 3 business hours
707+
Timestamp('2014-08-01 10:00') + BusinessHour(-3)
708+
709+
Also, you can specify ``start`` and ``end`` time by keywords.
710+
Argument must be ``str`` which has ``hour:minute`` representation or ``datetime.time`` instance.
711+
Specifying seconds, microseconds and nanoseconds as business hour results in ``ValueError``.
712+
713+
.. ipython:: python
714+
715+
bh = BusinessHour(start='11:00', end=time(20, 0))
716+
bh
717+
718+
Timestamp('2014-08-01 13:00') + bh
719+
Timestamp('2014-08-01 09:00') + bh
720+
Timestamp('2014-08-01 18:00') + bh
721+
722+
Passing ``start`` time later than ``end`` represents midnight business hour.
723+
In this case, business hour exceeds midnight and overlap to the next day.
724+
Valid business hours are distinguished by whether it started from valid ``BusinessDay``.
725+
726+
.. ipython:: python
727+
728+
bh = BusinessHour(start='17:00', end='09:00')
729+
bh
730+
731+
Timestamp('2014-08-01 17:00') + bh
732+
Timestamp('2014-08-01 23:00') + bh
733+
734+
# Although 2014-08-02 is Satuaday,
735+
# it is valid because it starts from 08-01 (Friday).
736+
Timestamp('2014-08-02 04:00') + bh
737+
738+
# Although 2014-08-04 is Monday,
739+
# it is out of business hours because it starts from 08-03 (Sunday).
740+
Timestamp('2014-08-04 04:00') + bh
741+
742+
Applying ``BusinessHour.rollforward`` and ``rollback`` to out of business hours results in
743+
the next business hour start or previous day's end. Different from other offsets, ``BusinessHour.rollforward``
744+
may output different results from ``apply`` by definition.
745+
746+
This is because one day's business hour end is equal to next day's business hour start. For example,
747+
under the default business hours (9:00 - 17:00), there is no gap (0 minutes) between ``2014-08-01 17:00`` and
748+
``2014-08-04 09:00``.
749+
750+
.. ipython:: python
751+
752+
# This adjusts a Timestamp to business hour edge
753+
BusinessHour().rollback(Timestamp('2014-08-02 15:00'))
754+
BusinessHour().rollforward(Timestamp('2014-08-02 15:00'))
755+
756+
# It is the same as BusinessHour().apply(Timestamp('2014-08-01 17:00')).
757+
# And it is the same as BusinessHour().apply(Timestamp('2014-08-04 09:00'))
758+
BusinessHour().apply(Timestamp('2014-08-02 15:00'))
759+
760+
# BusinessDay results (for reference)
761+
BusinessHour().rollforward(Timestamp('2014-08-02'))
762+
763+
# It is the same as BusinessDay().apply(Timestamp('2014-08-01'))
764+
# The result is the same as rollworward because BusinessDay never overlap.
765+
BusinessHour().apply(Timestamp('2014-08-02'))
766+
767+
670768
Offset Aliases
671769
~~~~~~~~~~~~~~
672770

@@ -696,6 +794,7 @@ frequencies. We will refer to these aliases as *offset aliases*
696794
"BA", "business year end frequency"
697795
"AS", "year start frequency"
698796
"BAS", "business year start frequency"
797+
"BH", "business hour frequency"
699798
"H", "hourly frequency"
700799
"T", "minutely frequency"
701800
"S", "secondly frequency"

doc/source/whatsnew/v0.15.2.txt

+9
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ enhancements, and performance improvements along with a large number of bug fixe
88
users upgrade to this version.
99

1010
- Highlights include:
11+
- ``BusinessHour`` offset is supported, see :ref:`here <timeseries.businesshour>`
1112

1213
- :ref:`Enhancements <whatsnew_0152.enhancements>`
1314
- :ref:`API Changes <whatsnew_0152.api>`
@@ -56,6 +57,14 @@ API changes
5657
p = pd.Panel(np.random.rand(2, 5, 4) > 0.1)
5758
p.all()
5859

60+
- ``BusinessHour`` offset is now supported, which represents business hours starting from 09:00 - 17:00 on ``BusinessDay`` by default. See :ref:`Here <timeseries.businesshour>` for details. (:issue:`7905`)
61+
62+
.. ipython:: python
63+
64+
Timestamp('2014-08-01 09:00') + BusinessHour()
65+
Timestamp('2014-08-01 07:00') + BusinessHour()
66+
Timestamp('2014-08-01 16:30') + BusinessHour()
67+
5968
.. _whatsnew_0152.enhancements:
6069

6170
Enhancements

pandas/tseries/frequencies.py

+17-3
Original file line numberDiff line numberDiff line change
@@ -714,15 +714,15 @@ def __init__(self, index, warn=True):
714714
@cache_readonly
715715
def deltas(self):
716716
return tslib.unique_deltas(self.values)
717-
717+
718718
@cache_readonly
719719
def deltas_asi8(self):
720720
return tslib.unique_deltas(self.index.asi8)
721721

722722
@cache_readonly
723723
def is_unique(self):
724724
return len(self.deltas) == 1
725-
725+
726726
@cache_readonly
727727
def is_unique_asi8(self):
728728
return len(self.deltas_asi8) == 1
@@ -734,8 +734,10 @@ def get_freq(self):
734734
delta = self.deltas[0]
735735
if _is_multiple(delta, _ONE_DAY):
736736
return self._infer_daily_rule()
737+
elif _is_multiple(delta, _ONE_HOUR):
738+
return self._infer_hourly_rule()
737739
else:
738-
# Possibly intraday frequency. Here we use the
740+
# Possibly intraday frequency. Here we use the
739741
# original .asi8 values as the modified values
740742
# will not work around DST transitions. See #8772
741743
if not self.is_unique_asi8:
@@ -764,6 +766,10 @@ def get_freq(self):
764766
def day_deltas(self):
765767
return [x / _ONE_DAY for x in self.deltas]
766768

769+
@cache_readonly
770+
def hour_deltas(self):
771+
return [x / _ONE_HOUR for x in self.deltas]
772+
767773
@cache_readonly
768774
def fields(self):
769775
return tslib.build_field_sarray(self.values)
@@ -859,6 +865,14 @@ def _infer_daily_rule(self):
859865
if wom_rule:
860866
return wom_rule
861867

868+
def _infer_hourly_rule(self):
869+
# Business hourly, maybe. 17: one day / 65: one weekend
870+
if self.hour_deltas in ([1, 17], [1, 65], [1, 17, 65]):
871+
return 'BH'
872+
elif not self.is_unique:
873+
return None
874+
return _maybe_add_count('H', self.deltas[0] / _ONE_HOUR)
875+
862876
def _get_annual_rule(self):
863877
if len(self.ydiffs) > 1:
864878
return None

0 commit comments

Comments
 (0)