From ecfc92e032a966e9bce5719632fcdb6347f81c1c Mon Sep 17 00:00:00 2001 From: Si Wei How Date: Wed, 15 May 2019 13:22:15 +0800 Subject: [PATCH 01/11] Fix BusinessHour docstring --- pandas/tseries/offsets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index c1764b3845fce..0a9019341b1f5 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -801,7 +801,7 @@ def _repr_attrs(self): class BusinessHour(BusinessHourMixin, SingleConstructorOffset): """ - DateOffset subclass representing possibly n business days. + DateOffset subclass representing possibly n business hours. .. versionadded:: 0.16.1 """ From 63f0b520973a6e3e1d8a6fbbd258403d17ba18a1 Mon Sep 17 00:00:00 2001 From: Si Wei How Date: Tue, 14 May 2019 16:00:30 +0800 Subject: [PATCH 02/11] Support multiple starting and ending times in BusinessHour --- doc/source/whatsnew/v0.25.0.rst | 2 +- pandas/tests/tseries/offsets/test_offsets.py | 260 ++++++++++++++++++- pandas/tseries/offsets.py | 229 ++++++++++------ 3 files changed, 408 insertions(+), 83 deletions(-) diff --git a/doc/source/whatsnew/v0.25.0.rst b/doc/source/whatsnew/v0.25.0.rst index 461c883f542ab..e21dcbbbb6357 100644 --- a/doc/source/whatsnew/v0.25.0.rst +++ b/doc/source/whatsnew/v0.25.0.rst @@ -82,7 +82,7 @@ Other Enhancements - :meth:`DataFrame.query` and :meth:`DataFrame.eval` now supports quoting column names with backticks to refer to names with spaces (:issue:`6508`) - :func:`merge_asof` now gives a more clear error message when merge keys are categoricals that are not equal (:issue:`26136`) - :meth:`pandas.core.window.Rolling` supports exponential (or Poisson) window type (:issue:`21303`) -- +- :class: `pandas.offsets.BusinessHour` supports multiple opening hours intervals .. _whatsnew_0250.api_breaking: diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py index 8c8a2f75c4a47..50e5d1e88f370 100644 --- a/pandas/tests/tseries/offsets/test_offsets.py +++ b/pandas/tests/tseries/offsets/test_offsets.py @@ -760,6 +760,12 @@ def setup_method(self, method): self.offset6 = BusinessHour(start='20:00', end='05:00') self.offset7 = BusinessHour(n=-2, start=dt_time(21, 30), end=dt_time(6, 30)) + self.offset8 = BusinessHour(start=['09:00', '13:00'], + end=['12:00', '17:00']) + self.offset9 = BusinessHour(n=3, start=['09:00', '22:00'], + end=['13:00', '03:00']) + self.offset10 = BusinessHour(n=-1, start=['23:00', '13:00'], + end=['02:00', '17:00']) def test_constructor_errors(self): from datetime import time as dt_time @@ -769,6 +775,14 @@ def test_constructor_errors(self): BusinessHour(start='AAA') with pytest.raises(ValueError): BusinessHour(start='14:00:05') + with pytest.raises(ValueError): + BusinessHour(start=[]) + with pytest.raises(ValueError): + BusinessHour(start=['09:00', '11:00']) + with pytest.raises(ValueError): + BusinessHour(start=['09:00', '11:00'], end=['10:00']) + with pytest.raises(ValueError): + BusinessHour(start=['09:00', '11:00'], end=['12:00', '20:00']) def test_different_normalize_equals(self): # GH#21404 changed __eq__ to return False when `normalize` doesnt match @@ -785,6 +799,12 @@ def test_repr(self): assert repr(self.offset5) == '' assert repr(self.offset6) == '' assert repr(self.offset7) == '<-2 * BusinessHours: BH=21:30-06:30>' + assert (repr(self.offset8) == + '') + assert (repr(self.offset9) == + '<3 * BusinessHours: BH=09:00-13:00,22:00-03:00>') + assert (repr(self.offset10) == + '<-1 * BusinessHour: BH=13:00-17:00,23:00-02:00>') def test_with_offset(self): expected = Timestamp('2014-07-01 13:00') @@ -793,7 +813,8 @@ def test_with_offset(self): assert self.d + BusinessHour(n=3) == expected def test_eq(self): - for offset in [self.offset1, self.offset2, self.offset3, self.offset4]: + for offset in [self.offset1, self.offset2, self.offset3, self.offset4, + self.offset8, self.offset9, self.offset10]: assert offset == offset assert BusinessHour() != BusinessHour(-1) @@ -802,8 +823,18 @@ def test_eq(self): assert (BusinessHour(start='09:00', end='17:00') != BusinessHour(start='17:00', end='09:01')) + assert (BusinessHour(start=['23:00', '13:00'], + end=['12:00', '17:00']) == + BusinessHour(start=['13:00', '23:00'], + end=['17:00', '12:00'])) + assert (BusinessHour(start=['13:00', '23:00'], + end=['18:00', '07:00']) != + BusinessHour(start=['13:00', '23:00'], + end=['17:00', '12:00'])) + def test_hash(self): - for offset in [self.offset1, self.offset2, self.offset3, self.offset4]: + for offset in [self.offset1, self.offset2, self.offset3, self.offset4, + self.offset8, self.offset9, self.offset10]: assert hash(offset) == hash(offset) def test_call(self): @@ -811,6 +842,9 @@ def test_call(self): assert self.offset2(self.d) == datetime(2014, 7, 1, 13) assert self.offset3(self.d) == datetime(2014, 6, 30, 17) assert self.offset4(self.d) == datetime(2014, 6, 30, 14) + assert self.offset8(self.d) == datetime(2014, 7, 1, 11) + assert self.offset9(self.d) == datetime(2014, 7, 1, 22) + assert self.offset10(self.d) == datetime(2014, 7, 1, 1) def test_sub(self): # we have to override test_sub here becasue self.offset2 is not @@ -831,6 +865,9 @@ def testRollback1(self): assert self.offset5.rollback(self.d) == datetime(2014, 6, 30, 14, 30) assert self.offset6.rollback(self.d) == datetime(2014, 7, 1, 5, 0) assert self.offset7.rollback(self.d) == datetime(2014, 7, 1, 6, 30) + assert self.offset8.rollback(self.d) == self.d + assert self.offset9.rollback(self.d) == self.d + assert self.offset10.rollback(self.d) == datetime(2014, 7, 1, 2) d = datetime(2014, 7, 1, 0) assert self.offset1.rollback(d) == datetime(2014, 6, 30, 17) @@ -840,6 +877,9 @@ def testRollback1(self): assert self.offset5.rollback(d) == datetime(2014, 6, 30, 14, 30) assert self.offset6.rollback(d) == d assert self.offset7.rollback(d) == d + assert self.offset8.rollback(d) == datetime(2014, 6, 30, 17) + assert self.offset9.rollback(d) == d + assert self.offset10.rollback(d) == d assert self._offset(5).rollback(self.d) == self.d @@ -858,6 +898,9 @@ def testRollforward1(self): datetime(2014, 7, 1, 20, 0)) assert (self.offset7.rollforward(self.d) == datetime(2014, 7, 1, 21, 30)) + assert self.offset8.rollforward(self.d) == self.d + assert self.offset9.rollforward(self.d) == self.d + assert self.offset10.rollforward(self.d) == datetime(2014, 7, 1, 13) d = datetime(2014, 7, 1, 0) assert self.offset1.rollforward(d) == datetime(2014, 7, 1, 9) @@ -867,6 +910,9 @@ def testRollforward1(self): assert self.offset5.rollforward(d) == datetime(2014, 7, 1, 11) assert self.offset6.rollforward(d) == d assert self.offset7.rollforward(d) == d + assert self.offset8.rollforward(d) == datetime(2014, 7, 1, 9) + assert self.offset9.rollforward(d) == d + assert self.offset10.rollforward(d) == d assert self._offset(5).rollforward(self.d) == self.d @@ -961,6 +1007,35 @@ def test_normalize(self, case): datetime(2014, 7, 6, 23, 0): False, datetime(2014, 7, 7, 3, 0): False})) + on_offset_cases.append((BusinessHour(start=['09:00', '13:00'], + end=['12:00', '17:00']), { + datetime(2014, 7, 1, 9): True, + datetime(2014, 7, 1, 8, 59): False, + datetime(2014, 7, 1, 8): False, + datetime(2014, 7, 1, 17): True, + datetime(2014, 7, 1, 17, 1): False, + datetime(2014, 7, 1, 18): False, + datetime(2014, 7, 5, 9): False, + datetime(2014, 7, 6, 12): False, + datetime(2014, 7, 1, 12, 30): False})) + + on_offset_cases.append((BusinessHour(start=['19:00', '23:00'], + end=['21:00', '05:00']), { + datetime(2014, 7, 1, 9, 0): False, + datetime(2014, 7, 1, 10, 0): False, + datetime(2014, 7, 1, 15): False, + datetime(2014, 7, 1, 15, 1): False, + datetime(2014, 7, 5, 12, 0): False, + datetime(2014, 7, 6, 12, 0): False, + datetime(2014, 7, 1, 19, 0): True, + datetime(2014, 7, 2, 0, 0): True, + datetime(2014, 7, 4, 23): True, + datetime(2014, 7, 5, 1): True, + datetime(2014, 7, 5, 5, 0): True, + datetime(2014, 7, 6, 23, 0): False, + datetime(2014, 7, 7, 3, 0): False, + datetime(2014, 7, 4, 22): False})) + @pytest.mark.parametrize('case', on_offset_cases) def test_onOffset(self, case): offset, cases = case @@ -1126,6 +1201,76 @@ def test_onOffset(self, case): datetime(2014, 7, 7, 18): (datetime(2014, 7, 7, 17), datetime(2014, 7, 8, 17))})) + opening_time_cases.append(([BusinessHour(start=['11:15', '15:00'], + end=['13:00', '20:00']), + BusinessHour(n=3, start=['11:15', '15:00'], + end=['12:00', '20:00']), + BusinessHour(start=['11:15', '15:00'], + end=['13:00', '17:00']), + BusinessHour(n=2, start=['11:15', '15:00'], + end=['12:00', '03:00']), + BusinessHour(n=3, start=['11:15', '15:00'], + end=['13:00', '16:00'])], { + datetime(2014, 7, 1, 11): (datetime(2014, 7, 1, 11, 15), + datetime(2014, 6, 30, 15)), + datetime(2014, 7, 1, 18): (datetime(2014, 7, 2, 11, 15), + datetime(2014, 7, 1, 15)), + datetime(2014, 7, 1, 23): (datetime(2014, 7, 2, 11, 15), + datetime(2014, 7, 1, 15)), + datetime(2014, 7, 2, 8): (datetime(2014, 7, 2, 11, 15), + datetime(2014, 7, 1, 15)), + datetime(2014, 7, 2, 9): (datetime(2014, 7, 2, 11, 15), + datetime(2014, 7, 1, 15)), + datetime(2014, 7, 2, 10): (datetime(2014, 7, 2, 11, 15), + datetime(2014, 7, 1, 15)), + datetime(2014, 7, 2, 11, 15): (datetime(2014, 7, 2, 11, 15), + datetime(2014, 7, 2, 11, 15)), + datetime(2014, 7, 2, 11, 15, 1): (datetime(2014, 7, 2, 15), + datetime(2014, 7, 2, 11, 15)), + datetime(2014, 7, 5, 10): (datetime(2014, 7, 7, 11, 15), + datetime(2014, 7, 4, 15)), + datetime(2014, 7, 4, 10): (datetime(2014, 7, 4, 11, 15), + datetime(2014, 7, 3, 15)), + datetime(2014, 7, 4, 23): (datetime(2014, 7, 7, 11, 15), + datetime(2014, 7, 4, 15)), + datetime(2014, 7, 6, 10): (datetime(2014, 7, 7, 11, 15), + datetime(2014, 7, 4, 15)), + datetime(2014, 7, 7, 5): (datetime(2014, 7, 7, 11, 15), + datetime(2014, 7, 4, 15)), + datetime(2014, 7, 7, 9, 1): (datetime(2014, 7, 7, 11, 15), + datetime(2014, 7, 4, 15)), + datetime(2014, 7, 7, 12): (datetime(2014, 7, 7, 15), + datetime(2014, 7, 7, 11, 15))})) + + opening_time_cases.append(([BusinessHour(n=-1, start=['17:00', '08:00'], + end=['05:00', '10:00']), + BusinessHour(n=-2, start=['08:00', '17:00'], + end=['10:00', '03:00'])], { + datetime(2014, 7, 1, 11): (datetime(2014, 7, 1, 8), + datetime(2014, 7, 1, 17)), + datetime(2014, 7, 1, 18): (datetime(2014, 7, 1, 17), + datetime(2014, 7, 2, 8)), + datetime(2014, 7, 1, 23): (datetime(2014, 7, 1, 17), + datetime(2014, 7, 2, 8)), + datetime(2014, 7, 2, 8): (datetime(2014, 7, 2, 8), + datetime(2014, 7, 2, 8)), + datetime(2014, 7, 2, 9): (datetime(2014, 7, 2, 8), + datetime(2014, 7, 2, 17)), + datetime(2014, 7, 2, 16, 59): (datetime(2014, 7, 2, 8), + datetime(2014, 7, 2, 17)), + datetime(2014, 7, 5, 10): (datetime(2014, 7, 4, 17), + datetime(2014, 7, 7, 8)), + datetime(2014, 7, 4, 10): (datetime(2014, 7, 4, 8), + datetime(2014, 7, 4, 17)), + datetime(2014, 7, 4, 23): (datetime(2014, 7, 4, 17), + datetime(2014, 7, 7, 8)), + datetime(2014, 7, 6, 10): (datetime(2014, 7, 4, 17), + datetime(2014, 7, 7, 8)), + datetime(2014, 7, 7, 5): (datetime(2014, 7, 4, 17), + datetime(2014, 7, 7, 8)), + datetime(2014, 7, 7, 18): (datetime(2014, 7, 7, 17), + datetime(2014, 7, 8, 8))})) + @pytest.mark.parametrize('case', opening_time_cases) def test_opening_time(self, case): _offsets, cases = case @@ -1304,6 +1449,81 @@ def test_opening_time(self, case): datetime(2014, 7, 7, 3, 30, 30): datetime(2014, 7, 4, 22, 30, 30), datetime(2014, 7, 7, 3, 30, 20): datetime(2014, 7, 4, 22, 30, 20)})) + # multiple business hours + apply_cases.append((BusinessHour(start=['09:00', '14:00'], + end=['12:00', '18:00']), { + datetime(2014, 7, 1, 11): datetime(2014, 7, 1, 14), + datetime(2014, 7, 1, 15): datetime(2014, 7, 1, 16), + datetime(2014, 7, 1, 19): datetime(2014, 7, 2, 10), + datetime(2014, 7, 1, 16): datetime(2014, 7, 1, 17), + datetime(2014, 7, 1, 16, 30, 15): datetime(2014, 7, 1, 17, 30, 15), + datetime(2014, 7, 1, 17): datetime(2014, 7, 2, 9), + datetime(2014, 7, 2, 11): datetime(2014, 7, 2, 14), + # out of business hours + datetime(2014, 7, 1, 13): datetime(2014, 7, 1, 15), + datetime(2014, 7, 2, 8): datetime(2014, 7, 2, 10), + datetime(2014, 7, 2, 19): datetime(2014, 7, 3, 10), + datetime(2014, 7, 2, 23): datetime(2014, 7, 3, 10), + datetime(2014, 7, 3, 0): datetime(2014, 7, 3, 10), + # saturday + datetime(2014, 7, 5, 15): datetime(2014, 7, 7, 10), + datetime(2014, 7, 4, 17): datetime(2014, 7, 7, 9), + datetime(2014, 7, 4, 17, 30): datetime(2014, 7, 7, 9, 30), + datetime(2014, 7, 4, 17, 30, 30): datetime(2014, 7, 7, 9, 30, 30)})) + + apply_cases.append((BusinessHour(n=4, start=['09:00', '14:00'], + end=['12:00', '18:00']), { + datetime(2014, 7, 1, 11): datetime(2014, 7, 1, 17), + datetime(2014, 7, 1, 13): datetime(2014, 7, 2, 9), + datetime(2014, 7, 1, 15): datetime(2014, 7, 2, 10), + datetime(2014, 7, 1, 16): datetime(2014, 7, 2, 11), + datetime(2014, 7, 1, 17): datetime(2014, 7, 2, 14), + datetime(2014, 7, 2, 11): datetime(2014, 7, 2, 17), + datetime(2014, 7, 2, 8): datetime(2014, 7, 2, 15), + datetime(2014, 7, 2, 19): datetime(2014, 7, 3, 15), + datetime(2014, 7, 2, 23): datetime(2014, 7, 3, 15), + datetime(2014, 7, 3, 0): datetime(2014, 7, 3, 15), + datetime(2014, 7, 5, 15): datetime(2014, 7, 7, 15), + datetime(2014, 7, 4, 17): datetime(2014, 7, 7, 14), + datetime(2014, 7, 4, 16, 30): datetime(2014, 7, 7, 11, 30), + datetime(2014, 7, 4, 16, 30, 30): datetime(2014, 7, 7, 11, 30, 30)})) + + apply_cases.append((BusinessHour(n=-4, start=['09:00', '14:00'], + end=['12:00', '18:00']), { + datetime(2014, 7, 1, 11): datetime(2014, 6, 30, 16), + datetime(2014, 7, 1, 13): datetime(2014, 6, 30, 17), + datetime(2014, 7, 1, 15): datetime(2014, 6, 30, 18), + datetime(2014, 7, 1, 16): datetime(2014, 7, 1, 10), + datetime(2014, 7, 1, 17): datetime(2014, 7, 1, 11), + datetime(2014, 7, 2, 11): datetime(2014, 7, 1, 16), + datetime(2014, 7, 2, 8): datetime(2014, 7, 1, 12), + datetime(2014, 7, 2, 19): datetime(2014, 7, 2, 12), + datetime(2014, 7, 2, 23): datetime(2014, 7, 2, 12), + datetime(2014, 7, 3, 0): datetime(2014, 7, 2, 12), + datetime(2014, 7, 5, 15): datetime(2014, 7, 4, 12), + datetime(2014, 7, 4, 18): datetime(2014, 7, 4, 12), + datetime(2014, 7, 7, 9, 30): datetime(2014, 7, 4, 14, 30), + datetime(2014, 7, 7, 9, 30, 30): datetime(2014, 7, 4, 14, 30, 30)})) + + apply_cases.append((BusinessHour(n=-1, start=['19:00', '03:00'], + end=['01:00', '05:00']), { + datetime(2014, 7, 1, 17): datetime(2014, 7, 1, 4), + datetime(2014, 7, 2, 14): datetime(2014, 7, 2, 4), + datetime(2014, 7, 2, 8): datetime(2014, 7, 2, 4), + datetime(2014, 7, 2, 13): datetime(2014, 7, 2, 4), + datetime(2014, 7, 2, 20): datetime(2014, 7, 2, 5), + datetime(2014, 7, 2, 19): datetime(2014, 7, 2, 4), + datetime(2014, 7, 2, 4): datetime(2014, 7, 2, 1), + datetime(2014, 7, 2, 19, 30): datetime(2014, 7, 2, 4, 30), + datetime(2014, 7, 3, 0): datetime(2014, 7, 2, 23), + datetime(2014, 7, 3, 6): datetime(2014, 7, 3, 4), + datetime(2014, 7, 4, 23): datetime(2014, 7, 4, 22), + datetime(2014, 7, 5, 0): datetime(2014, 7, 4, 23), + datetime(2014, 7, 5, 4): datetime(2014, 7, 5, 0), + datetime(2014, 7, 7, 3, 30): datetime(2014, 7, 5, 0, 30), + datetime(2014, 7, 7, 19, 30): datetime(2014, 7, 7, 4, 30), + datetime(2014, 7, 7, 19, 30, 30): datetime(2014, 7, 7, 4, 30, 30)})) + @pytest.mark.parametrize('case', apply_cases) def test_apply(self, case): offset, cases = case @@ -1360,6 +1580,42 @@ def test_apply(self, case): datetime(2014, 7, 7, 1): datetime(2014, 7, 15, 0), datetime(2014, 7, 7, 23, 30): datetime(2014, 7, 15, 21, 30)})) + # large n for multiple opening hours (3 days and 1 hour before) + apply_large_n_cases.append((BusinessHour(n=-25, start=['09:00', '14:00'], + end=['12:00', '19:00']), { + datetime(2014, 7, 1, 11): datetime(2014, 6, 26, 10), + datetime(2014, 7, 1, 13): datetime(2014, 6, 26, 11), + datetime(2014, 7, 1, 9): datetime(2014, 6, 25, 18), + datetime(2014, 7, 1, 10): datetime(2014, 6, 25, 19), + datetime(2014, 7, 3, 11): datetime(2014, 6, 30, 10), + datetime(2014, 7, 3, 8): datetime(2014, 6, 27, 18), + datetime(2014, 7, 3, 19): datetime(2014, 6, 30, 18), + datetime(2014, 7, 3, 23): datetime(2014, 6, 30, 18), + datetime(2014, 7, 4, 9): datetime(2014, 6, 30, 18), + datetime(2014, 7, 5, 15): datetime(2014, 7, 1, 18), + datetime(2014, 7, 6, 18): datetime(2014, 7, 1, 18), + datetime(2014, 7, 7, 9, 30): datetime(2014, 7, 1, 18, 30), + datetime(2014, 7, 7, 10, 30, 30): datetime(2014, 7, 2, 9, 30, 30)})) + + # 5 days and 3 hours later + apply_large_n_cases.append((BusinessHour(28, start=['21:00', '03:00'], + end=['01:00', '04:00']), { + datetime(2014, 7, 1, 11): datetime(2014, 7, 9, 0), + datetime(2014, 7, 1, 22): datetime(2014, 7, 9, 3), + datetime(2014, 7, 1, 23): datetime(2014, 7, 9, 21), + datetime(2014, 7, 2, 2): datetime(2014, 7, 9, 23), + datetime(2014, 7, 3, 21): datetime(2014, 7, 11, 0), + datetime(2014, 7, 4, 1): datetime(2014, 7, 11, 23), + datetime(2014, 7, 4, 2): datetime(2014, 7, 11, 23), + datetime(2014, 7, 4, 3): datetime(2014, 7, 11, 23), + datetime(2014, 7, 4, 21): datetime(2014, 7, 12, 0), + datetime(2014, 7, 5, 0): datetime(2014, 7, 14, 22), + datetime(2014, 7, 5, 1): datetime(2014, 7, 14, 23), + datetime(2014, 7, 5, 15): datetime(2014, 7, 14, 23), + datetime(2014, 7, 6, 18): datetime(2014, 7, 14, 23), + datetime(2014, 7, 7, 1): datetime(2014, 7, 14, 23), + datetime(2014, 7, 7, 23, 30): datetime(2014, 7, 15, 21, 30)})) + @pytest.mark.parametrize('case', apply_large_n_cases) def test_apply_large_n(self, case): offset, cases = case diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 0a9019341b1f5..084936f104cce 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -17,6 +17,7 @@ from pandas.util._decorators import Appender, Substitution, cache_readonly from pandas.core.dtypes.generic import ABCPeriod +from pandas.core.dtypes.inference import _iterable_not_string from pandas.core.tools.datetimes import to_datetime @@ -579,9 +580,45 @@ class BusinessHourMixin(BusinessMixin): def __init__(self, start='09:00', end='17:00', offset=timedelta(0)): # must be validated here to equality check - start = liboffsets._validate_business_time(start) + if _iterable_not_string(start): + start = np.asarray(start) + if len(start) == 0: + raise ValueError('Must include at least 1 start time') + else: + start = np.array([start]) + if _iterable_not_string(end): + end = np.asarray(end) + if len(end) == 0: + raise ValueError('Must include at least 1 end time') + else: + end = np.array([end]) + + vliboffsets = np.vectorize(liboffsets._validate_business_time) + start = vliboffsets(start) + end = vliboffsets(end) + + # Validation of input + if len(start) != len(end): + raise ValueError('number of starting time and ending time ' + 'must be the same') + num_openings = len(start) + + # sort starting and ending time by starting time + index = np.argsort(start) + start = tuple(start[index]) + end = tuple(end[index]) + + total_secs = 0 + for i in range(num_openings): + total_secs += self._get_business_hours_by_sec(start[i], end[i]) + total_secs += self._get_business_hours_by_sec( + end[i], start[(i + 1) % num_openings]) + if total_secs != 24 * 60 * 60: + raise ValueError('invalid starting and ending time(s): ' + 'opening hours should not touch or overlap with ' + 'one another') + object.__setattr__(self, "start", start) - end = liboffsets._validate_business_time(end) object.__setattr__(self, "end", end) object.__setattr__(self, "_offset", offset) @@ -603,61 +640,76 @@ def next_bday(self): else: return BusinessDay(n=nb_offset) - @cache_readonly - def _get_daytime_flag(self): - if self.start == self.end: - raise ValueError('start and end must not be the same') - elif self.start < self.end: - return True - else: - return False + def _get_daytime_flag(self, start, end): + return start < end - def _next_opening_time(self, other): + def _next_opening_time(self, other, sign=1): """ - If n is positive, return tomorrow's business day opening time. - Otherwise yesterday's business day's opening time. + If self.n and sign have the same sign, return the earliest opening time + later than or equal to current time. + Otherwise the latest opening time earlier than or equal to current + time. Opening time always locates on BusinessDay. - Otherwise, closing time may not if business hour extends over midnight. + However, closing time may not if business hour extends over midnight. """ + earliest_start = self.start[0] + latest_start = self.start[-1] if not self.next_bday.onOffset(other): - other = other + self.next_bday + # today is not business day + other = other + sign * self.next_bday + if self.n * sign >= 0: + return datetime(other.year, other.month, other.day, + earliest_start.hour, earliest_start.minute) + else: + return datetime(other.year, other.month, other.day, + latest_start.hour, latest_start.minute) else: - if self.n >= 0 and self.start < other.time(): - other = other + self.next_bday - elif self.n < 0 and other.time() < self.start: - other = other + self.next_bday - return datetime(other.year, other.month, other.day, - self.start.hour, self.start.minute) + if self.n * sign >= 0 and latest_start < other.time(): + # current time is after latest starting time in today + other = other + sign * self.next_bday + return datetime(other.year, other.month, other.day, + earliest_start.hour, earliest_start.minute) + elif self.n * sign < 0 and other.time() < earliest_start: + # current time is before earliest starting time in today + other = other + sign * self.next_bday + return datetime(other.year, other.month, other.day, + latest_start.hour, latest_start.minute) + if self.n * sign >= 0: + # find earliest starting time later than or equal to current time + for st in self.start: + if other.time() <= st: + return datetime(other.year, other.month, other.day, + st.hour, st.minute) + else: + # find latest starting time earlier than or equal to current time + for st in reversed(self.start): + if other.time() >= st: + return datetime(other.year, other.month, other.day, + st.hour, st.minute) def _prev_opening_time(self, other): """ - If n is positive, return yesterday's business day opening time. - Otherwise yesterday business day's opening time. + If n is positive, return the latest opening time earlier than or equal + to current time. + Otherwise the earliest opening time later than or equal to current + time. + """ - if not self.next_bday.onOffset(other): - other = other - self.next_bday - else: - if self.n >= 0 and other.time() < self.start: - other = other - self.next_bday - elif self.n < 0 and other.time() > self.start: - other = other - self.next_bday - return datetime(other.year, other.month, other.day, - self.start.hour, self.start.minute) + return self._next_opening_time(other, sign=-1) - @cache_readonly - def _get_business_hours_by_sec(self): + def _get_business_hours_by_sec(self, start, end): """ Return business hours in a day by seconds. """ - if self._get_daytime_flag: + if self._get_daytime_flag(start, end): # create dummy datetime to calculate businesshours in a day - dtstart = datetime(2014, 4, 1, self.start.hour, self.start.minute) - until = datetime(2014, 4, 1, self.end.hour, self.end.minute) + dtstart = datetime(2014, 4, 1, start.hour, start.minute) + until = datetime(2014, 4, 1, end.hour, end.minute) return (until - dtstart).total_seconds() else: - dtstart = datetime(2014, 4, 1, self.start.hour, self.start.minute) - until = datetime(2014, 4, 2, self.end.hour, self.end.minute) + dtstart = datetime(2014, 4, 1, start.hour, start.minute) + until = datetime(2014, 4, 2, end.hour, end.minute) return (until - dtstart).total_seconds() @apply_wraps @@ -666,13 +718,11 @@ def rollback(self, dt): Roll provided date backward to next offset only if not on offset. """ if not self.onOffset(dt): - businesshours = self._get_business_hours_by_sec if self.n >= 0: - dt = self._prev_opening_time( - dt) + timedelta(seconds=businesshours) + dt = self._prev_opening_time(dt) else: - dt = self._next_opening_time( - dt) + timedelta(seconds=businesshours) + dt = self._next_opening_time(dt) + return self._get_closing_time(dt) return dt @apply_wraps @@ -687,11 +737,15 @@ def rollforward(self, dt): return self._prev_opening_time(dt) return dt + def _get_closing_time(self, dt): + # dt is guaranteed to be a starting time + for i, st in enumerate(self.start): + if st.hour == dt.hour and st.minute == dt.minute: + return dt + timedelta( + seconds=self._get_business_hours_by_sec(st, self.end[i])) + @apply_wraps def apply(self, other): - businesshours = self._get_business_hours_by_sec - bhdelta = timedelta(seconds=businesshours) - if isinstance(other, datetime): # used for detecting edge condition nanosecond = getattr(other, 'nanosecond', 0) @@ -702,17 +756,19 @@ def apply(self, other): other.second, other.microsecond) n = self.n if n >= 0: - if (other.time() == self.end or - not self._onOffset(other, businesshours)): + if (other.time() in self.end or + not self._onOffset(other)): other = self._next_opening_time(other) else: - if other.time() == self.start: + if other.time() in self.start: # adjustment to move to previous business day other = other - timedelta(seconds=1) - if not self._onOffset(other, businesshours): + if not self._onOffset(other): other = self._next_opening_time(other) - other = other + bhdelta + other = self._get_closing_time(other) + businesshours = sum(self._get_business_hours_by_sec(st, en) + for st, en in zip(self.start, self.end)) bd, r = divmod(abs(n * 60), businesshours // 60) if n < 0: bd, r = -bd, -r @@ -721,43 +777,52 @@ def apply(self, other): skip_bd = BusinessDay(n=bd) # midnight business hour may not on BusinessDay if not self.next_bday.onOffset(other): - remain = other - self._prev_opening_time(other) - other = self._next_opening_time(other + skip_bd) + remain + prev_open = self._prev_opening_time(other) + remain = other - prev_open + other = prev_open + skip_bd + remain else: other = other + skip_bd hours, minutes = divmod(r, 60) - result = other + timedelta(hours=hours, minutes=minutes) + rem = timedelta(hours=hours, minutes=minutes) # because of previous adjustment, time will be larger than start if n >= 0: - bday_edge = self._prev_opening_time(other) + bhdelta - if bday_edge < result: - bday_remain = result - bday_edge - result = self._next_opening_time(other) - result += bday_remain + while rem != timedelta(0): + bhour_left = self._get_closing_time( + self._prev_opening_time(other)) - other + if bhour_left >= rem: + other = other + rem + rem = timedelta(0) + else: + rem = rem - bhour_left + other = self._next_opening_time(other + bhour_left) else: - bday_edge = self._next_opening_time(other) - if bday_edge > result: - bday_remain = result - bday_edge - result = self._next_opening_time(result) + bhdelta - result += bday_remain + while rem != timedelta(0): + bhour_left = self._next_opening_time(other) - other + if bhour_left <= rem: + other = other + rem + rem = timedelta(0) + else: + rem = rem - bhour_left + other = self._get_closing_time( + self._next_opening_time( + other + bhour_left - timedelta(seconds=1))) # edge handling if n >= 0: - if result.time() == self.end: - result = self._next_opening_time(result) + if other.time() in self.end: + other = self._next_opening_time(other) else: - if result.time() == self.start and nanosecond == 0: + if other.time() in self.start and nanosecond == 0: # adjustment to move to previous business day - result = self._next_opening_time( - result - timedelta(seconds=1)) + bhdelta + other = self._get_closing_time(self._next_opening_time( + other - timedelta(seconds=1))) - return result + return other else: - # TODO: Figure out the end of this sente raise ApplyTypeError( - 'Only know how to combine business hour with ') + 'Only know how to combine business hour with datetime') def onOffset(self, dt): if self.normalize and not _is_normalized(dt): @@ -768,10 +833,9 @@ def onOffset(self, dt): dt.minute, dt.second, dt.microsecond) # Valid BH can be on the different BusinessDay during midnight # Distinguish by the time spent from previous opening time - businesshours = self._get_business_hours_by_sec - return self._onOffset(dt, businesshours) + return self._onOffset(dt) - def _onOffset(self, dt, businesshours): + def _onOffset(self, dt): """ Slight speedups using calculated values. """ @@ -784,6 +848,11 @@ def _onOffset(self, dt, businesshours): else: op = self._next_opening_time(dt) span = (dt - op).total_seconds() + businesshours = 0 + for i, st in enumerate(self.start): + if op.hour == st.hour and op.minute == st.minute: + businesshours = self._get_business_hours_by_sec( + st, self.end[i]) if span <= businesshours: return True else: @@ -791,10 +860,10 @@ def _onOffset(self, dt, businesshours): def _repr_attrs(self): out = super()._repr_attrs() - start = self.start.strftime('%H:%M') - end = self.end.strftime('%H:%M') - attrs = ['{prefix}={start}-{end}'.format(prefix=self._prefix, - start=start, end=end)] + hours = ','.join('{}-{}'.format( + st.strftime('%H:%M'), en.strftime('%H:%M')) + for st, en in zip(self.start, self.end)) + attrs = ['{prefix}={hours}'.format(prefix=self._prefix, hours=hours)] out += ': ' + ', '.join(attrs) return out From 4cd7db1d432725e44ecee3f5ffd88711f64f5795 Mon Sep 17 00:00:00 2001 From: Si Wei How Date: Mon, 20 May 2019 09:32:01 +0800 Subject: [PATCH 03/11] Edit comments and tests --- pandas/tests/tseries/offsets/test_offsets.py | 26 ++++++++++---- pandas/tseries/offsets.py | 37 ++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py index 50e5d1e88f370..c5691c44bbd08 100644 --- a/pandas/tests/tseries/offsets/test_offsets.py +++ b/pandas/tests/tseries/offsets/test_offsets.py @@ -769,19 +769,31 @@ def setup_method(self, method): def test_constructor_errors(self): from datetime import time as dt_time - with pytest.raises(ValueError): + with pytest.raises(ValueError, + match='time data must be specified only with hour and minute'): BusinessHour(start=dt_time(11, 0, 5)) - with pytest.raises(ValueError): + with pytest.raises(ValueError, + match="time data must match '%H:%M' format"): BusinessHour(start='AAA') - with pytest.raises(ValueError): + with pytest.raises(ValueError, + match="time data must match '%H:%M' format"): BusinessHour(start='14:00:05') - with pytest.raises(ValueError): + with pytest.raises(ValueError, + match='number of starting time cannot be 0'): BusinessHour(start=[]) - with pytest.raises(ValueError): + with pytest.raises(ValueError, + match='number of ending time cannot be 0'): + BusinessHour(end=[]) + with pytest.raises(ValueError, + match='number of starting time and ending time ' + 'must be the same'): BusinessHour(start=['09:00', '11:00']) - with pytest.raises(ValueError): + with pytest.raises(ValueError, + match='number of starting time and ending time ' + 'must be the same'): BusinessHour(start=['09:00', '11:00'], end=['10:00']) - with pytest.raises(ValueError): + with pytest.raises(ValueError, + match=r'invalid starting and ending time\(s\)'): BusinessHour(start=['09:00', '11:00'], end=['12:00', '20:00']) def test_different_normalize_equals(self): diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 084936f104cce..9a521d3cacdac 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -583,13 +583,21 @@ def __init__(self, start='09:00', end='17:00', offset=timedelta(0)): if _iterable_not_string(start): start = np.asarray(start) if len(start) == 0: +<<<<<<< HEAD raise ValueError('Must include at least 1 start time') +======= + raise ValueError('number of starting time cannot be 0') +>>>>>>> Edit comments and tests else: start = np.array([start]) if _iterable_not_string(end): end = np.asarray(end) if len(end) == 0: +<<<<<<< HEAD raise ValueError('Must include at least 1 end time') +======= + raise ValueError('number of ending time cannot be 0') +>>>>>>> Edit comments and tests else: end = np.array([end]) @@ -645,10 +653,16 @@ def _get_daytime_flag(self, start, end): def _next_opening_time(self, other, sign=1): """ +<<<<<<< HEAD If self.n and sign have the same sign, return the earliest opening time later than or equal to current time. Otherwise the latest opening time earlier than or equal to current time. +======= + If self.n and dir have the same sign, return the earliest opening time + later than or equal to current time. + Otherwise the latest opening time earlier than or equal to current time. +>>>>>>> Edit comments and tests Opening time always locates on BusinessDay. However, closing time may not if business hour extends over midnight. @@ -657,14 +671,20 @@ def _next_opening_time(self, other, sign=1): latest_start = self.start[-1] if not self.next_bday.onOffset(other): # today is not business day +<<<<<<< HEAD other = other + sign * self.next_bday if self.n * sign >= 0: +======= + other = other + dir * self.next_bday + if self.n * dir >= 0: +>>>>>>> Edit comments and tests return datetime(other.year, other.month, other.day, earliest_start.hour, earliest_start.minute) else: return datetime(other.year, other.month, other.day, latest_start.hour, latest_start.minute) else: +<<<<<<< HEAD if self.n * sign >= 0 and latest_start < other.time(): # current time is after latest starting time in today other = other + sign * self.next_bday @@ -676,6 +696,19 @@ def _next_opening_time(self, other, sign=1): return datetime(other.year, other.month, other.day, latest_start.hour, latest_start.minute) if self.n * sign >= 0: +======= + if self.n * dir >= 0 and latest_start < other.time(): + # current time is after latest starting time in today + other = other + dir * self.next_bday + return datetime(other.year, other.month, other.day, + earliest_start.hour, earliest_start.minute) + elif self.n * dir < 0 and other.time() < earliest_start: + # current time is before earliest starting time in today + other = other + dir * self.next_bday + return datetime(other.year, other.month, other.day, + latest_start.hour, latest_start.minute) + if self.n * dir >= 0: +>>>>>>> Edit comments and tests # find earliest starting time later than or equal to current time for st in self.start: if other.time() <= st: @@ -692,8 +725,12 @@ def _prev_opening_time(self, other): """ If n is positive, return the latest opening time earlier than or equal to current time. +<<<<<<< HEAD Otherwise the earliest opening time later than or equal to current time. +======= + Otherwise the earliest opening time later than or equal to current time. +>>>>>>> Edit comments and tests """ return self._next_opening_time(other, sign=-1) From 170d08bdccf87c0a23f8ef29cb29d5f39ed51c94 Mon Sep 17 00:00:00 2001 From: Si Wei How Date: Mon, 20 May 2019 15:45:08 +0800 Subject: [PATCH 04/11] Edit error messages --- pandas/tests/tseries/offsets/test_offsets.py | 11 +++--- pandas/tseries/offsets.py | 37 -------------------- 2 files changed, 7 insertions(+), 41 deletions(-) diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py index c5691c44bbd08..36e56c589d250 100644 --- a/pandas/tests/tseries/offsets/test_offsets.py +++ b/pandas/tests/tseries/offsets/test_offsets.py @@ -770,7 +770,8 @@ def setup_method(self, method): def test_constructor_errors(self): from datetime import time as dt_time with pytest.raises(ValueError, - match='time data must be specified only with hour and minute'): + match='time data must be specified only with hour ' + 'and minute'): BusinessHour(start=dt_time(11, 0, 5)) with pytest.raises(ValueError, match="time data must match '%H:%M' format"): @@ -779,10 +780,10 @@ def test_constructor_errors(self): match="time data must match '%H:%M' format"): BusinessHour(start='14:00:05') with pytest.raises(ValueError, - match='number of starting time cannot be 0'): + match='Must include at least 1 start time'): BusinessHour(start=[]) with pytest.raises(ValueError, - match='number of ending time cannot be 0'): + match='Must include at least 1 end time'): BusinessHour(end=[]) with pytest.raises(ValueError, match='number of starting time and ending time ' @@ -793,7 +794,9 @@ def test_constructor_errors(self): 'must be the same'): BusinessHour(start=['09:00', '11:00'], end=['10:00']) with pytest.raises(ValueError, - match=r'invalid starting and ending time\(s\)'): + match=r'invalid starting and ending time\(s\): ' + 'opening hours should not touch or overlap with ' + 'one another'): BusinessHour(start=['09:00', '11:00'], end=['12:00', '20:00']) def test_different_normalize_equals(self): diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 9a521d3cacdac..084936f104cce 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -583,21 +583,13 @@ def __init__(self, start='09:00', end='17:00', offset=timedelta(0)): if _iterable_not_string(start): start = np.asarray(start) if len(start) == 0: -<<<<<<< HEAD raise ValueError('Must include at least 1 start time') -======= - raise ValueError('number of starting time cannot be 0') ->>>>>>> Edit comments and tests else: start = np.array([start]) if _iterable_not_string(end): end = np.asarray(end) if len(end) == 0: -<<<<<<< HEAD raise ValueError('Must include at least 1 end time') -======= - raise ValueError('number of ending time cannot be 0') ->>>>>>> Edit comments and tests else: end = np.array([end]) @@ -653,16 +645,10 @@ def _get_daytime_flag(self, start, end): def _next_opening_time(self, other, sign=1): """ -<<<<<<< HEAD If self.n and sign have the same sign, return the earliest opening time later than or equal to current time. Otherwise the latest opening time earlier than or equal to current time. -======= - If self.n and dir have the same sign, return the earliest opening time - later than or equal to current time. - Otherwise the latest opening time earlier than or equal to current time. ->>>>>>> Edit comments and tests Opening time always locates on BusinessDay. However, closing time may not if business hour extends over midnight. @@ -671,20 +657,14 @@ def _next_opening_time(self, other, sign=1): latest_start = self.start[-1] if not self.next_bday.onOffset(other): # today is not business day -<<<<<<< HEAD other = other + sign * self.next_bday if self.n * sign >= 0: -======= - other = other + dir * self.next_bday - if self.n * dir >= 0: ->>>>>>> Edit comments and tests return datetime(other.year, other.month, other.day, earliest_start.hour, earliest_start.minute) else: return datetime(other.year, other.month, other.day, latest_start.hour, latest_start.minute) else: -<<<<<<< HEAD if self.n * sign >= 0 and latest_start < other.time(): # current time is after latest starting time in today other = other + sign * self.next_bday @@ -696,19 +676,6 @@ def _next_opening_time(self, other, sign=1): return datetime(other.year, other.month, other.day, latest_start.hour, latest_start.minute) if self.n * sign >= 0: -======= - if self.n * dir >= 0 and latest_start < other.time(): - # current time is after latest starting time in today - other = other + dir * self.next_bday - return datetime(other.year, other.month, other.day, - earliest_start.hour, earliest_start.minute) - elif self.n * dir < 0 and other.time() < earliest_start: - # current time is before earliest starting time in today - other = other + dir * self.next_bday - return datetime(other.year, other.month, other.day, - latest_start.hour, latest_start.minute) - if self.n * dir >= 0: ->>>>>>> Edit comments and tests # find earliest starting time later than or equal to current time for st in self.start: if other.time() <= st: @@ -725,12 +692,8 @@ def _prev_opening_time(self, other): """ If n is positive, return the latest opening time earlier than or equal to current time. -<<<<<<< HEAD Otherwise the earliest opening time later than or equal to current time. -======= - Otherwise the earliest opening time later than or equal to current time. ->>>>>>> Edit comments and tests """ return self._next_opening_time(other, sign=-1) From e39a688ac0cb2bb1254e0da164f30cce2938cf29 Mon Sep 17 00:00:00 2001 From: How Si Wei Date: Fri, 7 Jun 2019 12:49:37 +0800 Subject: [PATCH 05/11] Update tests --- pandas/tests/tseries/offsets/test_offsets.py | 142 ++++++++++++------- pandas/tseries/offsets.py | 1 + 2 files changed, 90 insertions(+), 53 deletions(-) diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py index 36e56c589d250..c5515ffd24597 100644 --- a/pandas/tests/tseries/offsets/test_offsets.py +++ b/pandas/tests/tseries/offsets/test_offsets.py @@ -1,4 +1,4 @@ -from datetime import date, datetime, timedelta +from datetime import date, datetime, time as dt_time, timedelta from distutils.version import LooseVersion import numpy as np @@ -767,37 +767,53 @@ def setup_method(self, method): self.offset10 = BusinessHour(n=-1, start=['23:00', '13:00'], end=['02:00', '17:00']) - def test_constructor_errors(self): - from datetime import time as dt_time - with pytest.raises(ValueError, - match='time data must be specified only with hour ' - 'and minute'): - BusinessHour(start=dt_time(11, 0, 5)) - with pytest.raises(ValueError, - match="time data must match '%H:%M' format"): - BusinessHour(start='AAA') - with pytest.raises(ValueError, - match="time data must match '%H:%M' format"): - BusinessHour(start='14:00:05') - with pytest.raises(ValueError, - match='Must include at least 1 start time'): - BusinessHour(start=[]) - with pytest.raises(ValueError, - match='Must include at least 1 end time'): - BusinessHour(end=[]) + @pytest.mark.parametrize("start,end,match", [ + ( + dt_time(11, 0, 5), + '17:00', + "time data must be specified only with hour and minute" + ), + ( + 'AAA', + '17:00', + "time data must match '%H:%M' format" + ), + ( + '14:00:05', + '17:00', + "time data must match '%H:%M' format" + ), + ( + [], + '17:00', + "Must include at least 1 start time" + ), + ( + '09:00', + [], + "Must include at least 1 end time" + ), + ( + ['09:00', '11:00'], + '17:00', + "number of starting time and ending time must be the same" + ), + ( + ['09:00', '11:00'], + ['10:00'], + "number of starting time and ending time must be the same" + ), + ( + ['09:00', '11:00'], + ['12:00', '20:00'], + r"invalid starting and ending time\(s\): opening hours should not " + "touch or overlap with one another" + ), + ]) + def test_constructor_errors(self, start, end, match): with pytest.raises(ValueError, - match='number of starting time and ending time ' - 'must be the same'): - BusinessHour(start=['09:00', '11:00']) - with pytest.raises(ValueError, - match='number of starting time and ending time ' - 'must be the same'): - BusinessHour(start=['09:00', '11:00'], end=['10:00']) - with pytest.raises(ValueError, - match=r'invalid starting and ending time\(s\): ' - 'opening hours should not touch or overlap with ' - 'one another'): - BusinessHour(start=['09:00', '11:00'], end=['12:00', '20:00']) + match=match): + BusinessHour(start=start, end=end) def test_different_normalize_equals(self): # GH#21404 changed __eq__ to return False when `normalize` doesnt match @@ -827,30 +843,50 @@ def test_with_offset(self): assert self.d + BusinessHour() * 3 == expected assert self.d + BusinessHour(n=3) == expected - def test_eq(self): - for offset in [self.offset1, self.offset2, self.offset3, self.offset4, - self.offset8, self.offset9, self.offset10]: - assert offset == offset + @pytest.mark.parametrize("offset_name", [ + "offset1", + "offset2", + "offset3", + "offset4", + "offset8", + "offset9", + "offset10" + ]) + def test_eq_attribute(self, offset_name): + offset = getattr(self, offset_name) + assert offset == offset + + @pytest.mark.parametrize("offset1,offset2", [ + (BusinessHour(start='09:00'), BusinessHour()), + (BusinessHour(start=['23:00', '13:00'], end=['12:00', '17:00']), + BusinessHour(start=['13:00', '23:00'], end=['17:00', '12:00'])), + ]) + def test_eq(self, offset1, offset2): + assert offset1 == offset2 - assert BusinessHour() != BusinessHour(-1) - assert BusinessHour(start='09:00') == BusinessHour() - assert BusinessHour(start='09:00') != BusinessHour(start='09:01') - assert (BusinessHour(start='09:00', end='17:00') != - BusinessHour(start='17:00', end='09:01')) - - assert (BusinessHour(start=['23:00', '13:00'], - end=['12:00', '17:00']) == - BusinessHour(start=['13:00', '23:00'], - end=['17:00', '12:00'])) - assert (BusinessHour(start=['13:00', '23:00'], - end=['18:00', '07:00']) != - BusinessHour(start=['13:00', '23:00'], - end=['17:00', '12:00'])) + @pytest.mark.parametrize("offset1,offset2", [ + (BusinessHour(), BusinessHour(-1)), + (BusinessHour(start='09:00'), BusinessHour(start='09:01')), + (BusinessHour(start='09:00', end='17:00'), + BusinessHour(start='17:00', end='09:01')), + (BusinessHour(start=['13:00', '23:00'], end=['18:00', '07:00']), + BusinessHour(start=['13:00', '23:00'], end=['17:00', '12:00'])), + ]) + def test_neq(self, offset1, offset2): + assert offset1 != offset2 - def test_hash(self): - for offset in [self.offset1, self.offset2, self.offset3, self.offset4, - self.offset8, self.offset9, self.offset10]: - assert hash(offset) == hash(offset) + @pytest.mark.parametrize("offset_name", [ + "offset1", + "offset2", + "offset3", + "offset4", + "offset8", + "offset9", + "offset10" + ]) + def test_hash(self, offset_name): + offset = getattr(self, offset_name) + assert offset == offset def test_call(self): assert self.offset1(self.d) == datetime(2014, 7, 1, 11) diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 084936f104cce..118d88d7a0401 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -605,6 +605,7 @@ def __init__(self, start='09:00', end='17:00', offset=timedelta(0)): # sort starting and ending time by starting time index = np.argsort(start) + # convert to tuple so that start and end are hashable start = tuple(start[index]) end = tuple(end[index]) From 097a0fa0b4138ada8c8a507ee3c35f09a95b7000 Mon Sep 17 00:00:00 2001 From: How Si Wei Date: Sat, 8 Jun 2019 14:45:47 +0800 Subject: [PATCH 06/11] Add test --- pandas/tests/tseries/offsets/test_offsets.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py index c5515ffd24597..7ff22bdd1b554 100644 --- a/pandas/tests/tseries/offsets/test_offsets.py +++ b/pandas/tests/tseries/offsets/test_offsets.py @@ -809,6 +809,12 @@ def setup_method(self, method): r"invalid starting and ending time\(s\): opening hours should not " "touch or overlap with one another" ), + ( + ['12:00', '20:00'], + ['09:00', '11:00'], + r"invalid starting and ending time\(s\): opening hours should not " + "touch or overlap with one another" + ), ]) def test_constructor_errors(self, start, end, match): with pytest.raises(ValueError, From 14788455be25031f9787fb7b2333d18d6bacf1a5 Mon Sep 17 00:00:00 2001 From: How Si Wei Date: Tue, 11 Jun 2019 17:05:20 +0800 Subject: [PATCH 07/11] Update code and comments --- doc/source/whatsnew/v0.25.0.rst | 2 +- pandas/tseries/offsets.py | 200 ++++++++++++++++++-------------- 2 files changed, 114 insertions(+), 88 deletions(-) diff --git a/doc/source/whatsnew/v0.25.0.rst b/doc/source/whatsnew/v0.25.0.rst index e21dcbbbb6357..b99c9b5d7ab2c 100644 --- a/doc/source/whatsnew/v0.25.0.rst +++ b/doc/source/whatsnew/v0.25.0.rst @@ -82,7 +82,7 @@ Other Enhancements - :meth:`DataFrame.query` and :meth:`DataFrame.eval` now supports quoting column names with backticks to refer to names with spaces (:issue:`6508`) - :func:`merge_asof` now gives a more clear error message when merge keys are categoricals that are not equal (:issue:`26136`) - :meth:`pandas.core.window.Rolling` supports exponential (or Poisson) window type (:issue:`21303`) -- :class: `pandas.offsets.BusinessHour` supports multiple opening hours intervals +- :class:`pandas.offsets.BusinessHour` supports multiple opening hours intervals (:issue:`15481`) .. _whatsnew_0250.api_breaking: diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 118d88d7a0401..e51ee4e2b1863 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -17,7 +17,7 @@ from pandas.util._decorators import Appender, Substitution, cache_readonly from pandas.core.dtypes.generic import ABCPeriod -from pandas.core.dtypes.inference import _iterable_not_string +from pandas.core.dtypes.inference import is_list_like from pandas.core.tools.datetimes import to_datetime @@ -580,22 +580,19 @@ class BusinessHourMixin(BusinessMixin): def __init__(self, start='09:00', end='17:00', offset=timedelta(0)): # must be validated here to equality check - if _iterable_not_string(start): - start = np.asarray(start) - if len(start) == 0: - raise ValueError('Must include at least 1 start time') - else: - start = np.array([start]) - if _iterable_not_string(end): - end = np.asarray(end) - if len(end) == 0: - raise ValueError('Must include at least 1 end time') - else: - end = np.array([end]) + if not is_list_like(start): + start = [start] + if not len(start): + raise ValueError('Must include at least 1 start time') + + if not is_list_like(end): + end = [end] + if not len(end): + raise ValueError('Must include at least 1 end time') vliboffsets = np.vectorize(liboffsets._validate_business_time) - start = vliboffsets(start) - end = vliboffsets(end) + start = vliboffsets(np.asarray(start)) + end = vliboffsets(np.asarray(end)) # Validation of input if len(start) != len(end): @@ -605,6 +602,7 @@ def __init__(self, start='09:00', end='17:00', offset=timedelta(0)): # sort starting and ending time by starting time index = np.argsort(start) + # convert to tuple so that start and end are hashable start = tuple(start[index]) end = tuple(end[index]) @@ -641,10 +639,7 @@ def next_bday(self): else: return BusinessDay(n=nb_offset) - def _get_daytime_flag(self, start, end): - return start < end - - def _next_opening_time(self, other, sign=1): + def _next_opening_time(self, other: datetime, sign: int=1) -> datetime: """ If self.n and sign have the same sign, return the earliest opening time later than or equal to current time. @@ -653,68 +648,87 @@ def _next_opening_time(self, other, sign=1): Opening time always locates on BusinessDay. However, closing time may not if business hour extends over midnight. + + Parameters + ---------- + other : datetime + Current time. + sign : int, default 1. + Either 1 or -1. Going forward in time if it has the same sign as + self.n. Going backward in time otherwise. + + Returns + ------- + result : datetime + Next opening time. """ earliest_start = self.start[0] latest_start = self.start[-1] + if not self.next_bday.onOffset(other): # today is not business day other = other + sign * self.next_bday if self.n * sign >= 0: - return datetime(other.year, other.month, other.day, - earliest_start.hour, earliest_start.minute) + hour, minute = earliest_start.hour, earliest_start.minute else: - return datetime(other.year, other.month, other.day, - latest_start.hour, latest_start.minute) - else: - if self.n * sign >= 0 and latest_start < other.time(): - # current time is after latest starting time in today - other = other + sign * self.next_bday - return datetime(other.year, other.month, other.day, - earliest_start.hour, earliest_start.minute) - elif self.n * sign < 0 and other.time() < earliest_start: - # current time is before earliest starting time in today - other = other + sign * self.next_bday - return datetime(other.year, other.month, other.day, - latest_start.hour, latest_start.minute) - if self.n * sign >= 0: - # find earliest starting time later than or equal to current time - for st in self.start: - if other.time() <= st: - return datetime(other.year, other.month, other.day, - st.hour, st.minute) + hour, minute = latest_start.hour, latest_start.minute else: - # find latest starting time earlier than or equal to current time - for st in reversed(self.start): - if other.time() >= st: - return datetime(other.year, other.month, other.day, - st.hour, st.minute) + if self.n * sign >= 0: + if latest_start < other.time(): + # current time is after latest starting time in today + other = other + sign * self.next_bday + hour, minute = earliest_start.hour, earliest_start.minute + else: + # find earliest starting time later than or equal to current time + for st in self.start: + if other.time() <= st: + hour, minute = st.hour, st.minute + break + else: + if other.time() < earliest_start: + # current time is before earliest starting time in today + other = other + sign * self.next_bday + hour, minute = latest_start.hour, latest_start.minute + else: + # find latest starting time earlier than or equal to current time + for st in reversed(self.start): + if other.time() >= st: + hour, minute = st.hour, st.minute + break + + return datetime(other.year, other.month, other.day, hour, minute) - def _prev_opening_time(self, other): + def _prev_opening_time(self, other: datetime) -> datetime: """ If n is positive, return the latest opening time earlier than or equal to current time. Otherwise the earliest opening time later than or equal to current time. + Parameters + ---------- + other : datetime + Current time. + + Returns + ------- + result : datetime + Previous opening time. """ return self._next_opening_time(other, sign=-1) - def _get_business_hours_by_sec(self, start, end): + def _get_business_hours_by_sec(self, start: datetime, end: datetime) -> int: """ Return business hours in a day by seconds. """ - if self._get_daytime_flag(start, end): - # create dummy datetime to calculate businesshours in a day - dtstart = datetime(2014, 4, 1, start.hour, start.minute) - until = datetime(2014, 4, 1, end.hour, end.minute) - return (until - dtstart).total_seconds() - else: - dtstart = datetime(2014, 4, 1, start.hour, start.minute) - until = datetime(2014, 4, 2, end.hour, end.minute) - return (until - dtstart).total_seconds() + # create dummy datetime to calculate businesshours in a day + dtstart = datetime(2014, 4, 1, start.hour, start.minute) + day = 1 if start < end else 2 + until = datetime(2014, 4, day, end.hour, end.minute) + return (until - dtstart).total_seconds() @apply_wraps - def rollback(self, dt): + def rollback(self, dt: datetime) -> datetime: """ Roll provided date backward to next offset only if not on offset. """ @@ -727,7 +741,7 @@ def rollback(self, dt): return dt @apply_wraps - def rollforward(self, dt): + def rollforward(self, dt: datetime) -> datetime: """ Roll provided date forward to next offset only if not on offset. """ @@ -738,8 +752,20 @@ def rollforward(self, dt): return self._prev_opening_time(dt) return dt - def _get_closing_time(self, dt): - # dt is guaranteed to be a starting time + def _get_closing_time(self, dt: datetime) -> datetime: + """ + Get the closing time of a business hour interval by its opening time. + + Parameters + ---------- + dt : datetime + Opening time of a business hour interval. + + Returns + ------- + result : datetime + Corresponding closing time. + """ for i, st in enumerate(self.start): if st.hour == dt.hour and st.minute == dt.minute: return dt + timedelta( @@ -756,6 +782,8 @@ def apply(self, other): other.hour, other.minute, other.second, other.microsecond) n = self.n + + # adjust other to reduce cases to handle if n >= 0: if (other.time() in self.end or not self._onOffset(other)): @@ -768,12 +796,15 @@ def apply(self, other): other = self._next_opening_time(other) other = self._get_closing_time(other) + # get total business hours by sec in one business day businesshours = sum(self._get_business_hours_by_sec(st, en) for st, en in zip(self.start, self.end)) + bd, r = divmod(abs(n * 60), businesshours // 60) if n < 0: bd, r = -bd, -r + # adjust by business days first if bd != 0: skip_bd = BusinessDay(n=bd) # midnight business hour may not on BusinessDay @@ -784,41 +815,36 @@ def apply(self, other): else: other = other + skip_bd - hours, minutes = divmod(r, 60) - rem = timedelta(hours=hours, minutes=minutes) + # remaining business hours to adjust + bhour_remain = timedelta(minutes=r) - # because of previous adjustment, time will be larger than start if n >= 0: - while rem != timedelta(0): - bhour_left = self._get_closing_time( + while bhour_remain != timedelta(0): + # business hour left in this business time interval + bhour = self._get_closing_time( self._prev_opening_time(other)) - other - if bhour_left >= rem: - other = other + rem - rem = timedelta(0) + if bhour_remain < bhour: + # finish adjusting if possible + other += bhour_remain + bhour_remain = timedelta(0) else: - rem = rem - bhour_left - other = self._next_opening_time(other + bhour_left) + # go to next business time interval + bhour_remain -= bhour + other = self._next_opening_time(other + bhour) else: - while rem != timedelta(0): - bhour_left = self._next_opening_time(other) - other - if bhour_left <= rem: - other = other + rem - rem = timedelta(0) + while bhour_remain != timedelta(0): + # business hour left in this business time interval + bhour = self._next_opening_time(other) - other + if bhour_remain > bhour or bhour_remain == bhour and nanosecond != 0: + # finish adjusting if possible + other += bhour_remain + bhour_remain = timedelta(0) else: - rem = rem - bhour_left + # go to next business time interval + bhour_remain -= bhour other = self._get_closing_time( self._next_opening_time( - other + bhour_left - timedelta(seconds=1))) - - # edge handling - if n >= 0: - if other.time() in self.end: - other = self._next_opening_time(other) - else: - if other.time() in self.start and nanosecond == 0: - # adjustment to move to previous business day - other = self._get_closing_time(self._next_opening_time( - other - timedelta(seconds=1))) + other + bhour - timedelta(seconds=1))) return other else: From 7575597b89609b33919b0c9c485f1e84c7ed57f0 Mon Sep 17 00:00:00 2001 From: How Si Wei Date: Tue, 11 Jun 2019 17:21:11 +0800 Subject: [PATCH 08/11] Fix lint errors --- pandas/tseries/offsets.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index e51ee4e2b1863..19899117bf092 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -639,7 +639,7 @@ def next_bday(self): else: return BusinessDay(n=nb_offset) - def _next_opening_time(self, other: datetime, sign: int=1) -> datetime: + def _next_opening_time(self, other: datetime, sign: int = 1) -> datetime: """ If self.n and sign have the same sign, return the earliest opening time later than or equal to current time. @@ -679,7 +679,7 @@ def _next_opening_time(self, other: datetime, sign: int=1) -> datetime: other = other + sign * self.next_bday hour, minute = earliest_start.hour, earliest_start.minute else: - # find earliest starting time later than or equal to current time + # find earliest starting time no earlier than current time for st in self.start: if other.time() <= st: hour, minute = st.hour, st.minute @@ -690,7 +690,7 @@ def _next_opening_time(self, other: datetime, sign: int=1) -> datetime: other = other + sign * self.next_bday hour, minute = latest_start.hour, latest_start.minute else: - # find latest starting time earlier than or equal to current time + # find latest starting time no later than current time for st in reversed(self.start): if other.time() >= st: hour, minute = st.hour, st.minute @@ -717,7 +717,8 @@ def _prev_opening_time(self, other: datetime) -> datetime: """ return self._next_opening_time(other, sign=-1) - def _get_business_hours_by_sec(self, start: datetime, end: datetime) -> int: + def _get_business_hours_by_sec(self, start: datetime, end: datetime + ) -> int: """ Return business hours in a day by seconds. """ @@ -783,7 +784,7 @@ def apply(self, other): other.second, other.microsecond) n = self.n - # adjust other to reduce cases to handle + # adjust other to reduce number of cases to handle if n >= 0: if (other.time() in self.end or not self._onOffset(other)): @@ -835,7 +836,8 @@ def apply(self, other): while bhour_remain != timedelta(0): # business hour left in this business time interval bhour = self._next_opening_time(other) - other - if bhour_remain > bhour or bhour_remain == bhour and nanosecond != 0: + if (bhour_remain > bhour or + bhour_remain == bhour and nanosecond != 0): # finish adjusting if possible other += bhour_remain bhour_remain = timedelta(0) From 6b72ceaf76701c99f3fee48942b1d398cba9d6e2 Mon Sep 17 00:00:00 2001 From: How Si Wei Date: Wed, 12 Jun 2019 02:57:55 +0800 Subject: [PATCH 09/11] Fix mypy errors --- pandas/tseries/offsets.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 19899117bf092..39c984847640d 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -1,7 +1,7 @@ -from datetime import date, datetime, timedelta +from datetime import date, datetime, time as dt_time, timedelta import functools import operator -from typing import Optional +from typing import List, Optional from dateutil.easter import easter import numpy as np @@ -577,6 +577,9 @@ def onOffset(self, dt): class BusinessHourMixin(BusinessMixin): + start: List[dt_time] + end: List[dt_time] + n: int def __init__(self, start='09:00', end='17:00', offset=timedelta(0)): # must be validated here to equality check @@ -717,8 +720,7 @@ def _prev_opening_time(self, other: datetime) -> datetime: """ return self._next_opening_time(other, sign=-1) - def _get_business_hours_by_sec(self, start: datetime, end: datetime - ) -> int: + def _get_business_hours_by_sec(self, start: dt_time, end: dt_time) -> int: """ Return business hours in a day by seconds. """ @@ -726,7 +728,7 @@ def _get_business_hours_by_sec(self, start: datetime, end: datetime dtstart = datetime(2014, 4, 1, start.hour, start.minute) day = 1 if start < end else 2 until = datetime(2014, 4, day, end.hour, end.minute) - return (until - dtstart).total_seconds() + return int((until - dtstart).total_seconds()) @apply_wraps def rollback(self, dt: datetime) -> datetime: @@ -771,6 +773,7 @@ def _get_closing_time(self, dt: datetime) -> datetime: if st.hour == dt.hour and st.minute == dt.minute: return dt + timedelta( seconds=self._get_business_hours_by_sec(st, self.end[i])) + assert False @apply_wraps def apply(self, other): From 90fa7444eb9b63aa2ae9ab88147ad371fb58211d Mon Sep 17 00:00:00 2001 From: How Si Wei Date: Wed, 26 Jun 2019 23:57:09 +0800 Subject: [PATCH 10/11] Remove type signatures as they don't play well with python 3.5 --- pandas/tseries/offsets.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 39c984847640d..b6d72db766734 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -1,7 +1,7 @@ -from datetime import date, datetime, time as dt_time, timedelta +from datetime import date, datetime, timedelta import functools import operator -from typing import List, Optional +from typing import Optional from dateutil.easter import easter import numpy as np @@ -577,9 +577,6 @@ def onOffset(self, dt): class BusinessHourMixin(BusinessMixin): - start: List[dt_time] - end: List[dt_time] - n: int def __init__(self, start='09:00', end='17:00', offset=timedelta(0)): # must be validated here to equality check @@ -642,7 +639,7 @@ def next_bday(self): else: return BusinessDay(n=nb_offset) - def _next_opening_time(self, other: datetime, sign: int = 1) -> datetime: + def _next_opening_time(self, other, sign=1): """ If self.n and sign have the same sign, return the earliest opening time later than or equal to current time. @@ -701,7 +698,7 @@ def _next_opening_time(self, other: datetime, sign: int = 1) -> datetime: return datetime(other.year, other.month, other.day, hour, minute) - def _prev_opening_time(self, other: datetime) -> datetime: + def _prev_opening_time(self, other): """ If n is positive, return the latest opening time earlier than or equal to current time. @@ -720,7 +717,7 @@ def _prev_opening_time(self, other: datetime) -> datetime: """ return self._next_opening_time(other, sign=-1) - def _get_business_hours_by_sec(self, start: dt_time, end: dt_time) -> int: + def _get_business_hours_by_sec(self, start, end): """ Return business hours in a day by seconds. """ @@ -731,7 +728,7 @@ def _get_business_hours_by_sec(self, start: dt_time, end: dt_time) -> int: return int((until - dtstart).total_seconds()) @apply_wraps - def rollback(self, dt: datetime) -> datetime: + def rollback(self, dt): """ Roll provided date backward to next offset only if not on offset. """ @@ -744,7 +741,7 @@ def rollback(self, dt: datetime) -> datetime: return dt @apply_wraps - def rollforward(self, dt: datetime) -> datetime: + def rollforward(self, dt): """ Roll provided date forward to next offset only if not on offset. """ @@ -755,7 +752,7 @@ def rollforward(self, dt: datetime) -> datetime: return self._prev_opening_time(dt) return dt - def _get_closing_time(self, dt: datetime) -> datetime: + def _get_closing_time(self, dt): """ Get the closing time of a business hour interval by its opening time. From 61791979ce10af12bc0d4acc05daa7c246ba18eb Mon Sep 17 00:00:00 2001 From: How Si Wei Date: Fri, 28 Jun 2019 20:33:11 +0800 Subject: [PATCH 11/11] Make minor changes --- pandas/tseries/offsets.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 148578226122e..087c05574090c 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -592,9 +592,9 @@ def __init__(self, start='09:00', end='17:00', offset=timedelta(0)): if not len(end): raise ValueError('Must include at least 1 end time') - vliboffsets = np.vectorize(liboffsets._validate_business_time) - start = vliboffsets(np.asarray(start)) - end = vliboffsets(np.asarray(end)) + start = np.array([liboffsets._validate_business_time(x) + for x in start]) + end = np.array([liboffsets._validate_business_time(x) for x in end]) # Validation of input if len(start) != len(end):