From 0a58cba94158d85000374636adbbbbe4cfce2b27 Mon Sep 17 00:00:00 2001 From: Low Date: Sun, 27 Jan 2019 17:04:51 -0500 Subject: [PATCH 01/10] Quarter offset implemented (base is now latest pydata-master). --- xarray/coding/cftime_offsets.py | 361 ++++++++++++++++++++++++---- xarray/tests/test_cftime_offsets.py | 214 +++++++++++++++-- 2 files changed, 499 insertions(+), 76 deletions(-) diff --git a/xarray/coding/cftime_offsets.py b/xarray/coding/cftime_offsets.py index a373aeff747..0b83af89707 100644 --- a/xarray/coding/cftime_offsets.py +++ b/xarray/coding/cftime_offsets.py @@ -75,6 +75,7 @@ def get_date_type(calendar): class BaseCFTimeOffset(object): _freq = None # type: ClassVar[str] + _day_option = None def __init__(self, n=1): if not isinstance(n, int): @@ -151,6 +152,53 @@ def __str__(self): def __repr__(self): return str(self) + def _get_offset_day(self, other): + # subclass must implement `_day_option`; calling from the base class + # will raise NotImplementedError. + return _get_day_of_month(other, self._day_option) + + +def _is_normalized(datetime): + if (datetime.hour != 0 or datetime.minute != 0 or datetime.second != 0 or + datetime.microsecond != 0): + return False + return True + + +def _get_day_of_month(other, day_option): + """Find the day in `other`'s month that satisfies a DateOffset's onOffset + policy, as described by the `day_opt` argument. + + Parameters + ---------- + other : cftime.datetime + day_option : 'start', 'end', or int + 'start': returns 1 + 'end': returns last day of the month + int: returns the day in the month indicated by `other`, or the last of + day the month if the value exceeds in that month's number of days. + + Returns + ------- + day_of_month : int + + """ + + if day_option == 'start': + return 1 + elif day_option == 'end': + days_in_month = _days_in_month(other) + return days_in_month + elif isinstance(day_option, np.integer): + days_in_month = _days_in_month(other) + return min(day_option, days_in_month) + elif day_option is None: + # Note: unlike `_shift_month`, get_day_of_month does not + # allow day_option = None + raise NotImplementedError + else: + raise ValueError(day_option) + def _days_in_month(date): """The number of days in the month of the given date""" @@ -186,7 +234,7 @@ def _adjust_n_years(other, n, month, reference_day): return n -def _shift_months(date, months, day_option='start'): +def _shift_month(date, months, day_option='start'): """Shift the date to a month start or end a given number of months away. """ delta_year = (date.month + months) // 12 @@ -211,12 +259,53 @@ def _shift_months(date, months, day_option='start'): return date.replace(year=year, month=month, day=day, dayofwk=-1) +def roll_qtrday(other, n, month, day_option, modby=3): + """Possibly increment or decrement the number of periods to shift + based on rollforward/rollbackward conventions. + + Parameters + ---------- + other : cftime.datetime + n : number of periods to increment, before adjusting for rolling + month : int reference month giving the first month of the year + day_option : 'start', 'end', 'business_start', 'business_end', or int + The convention to use in finding the day in a given month against + which to compare for rollforward/rollbackward decisions. + modby : int 3 for quarters, 12 for years + + Returns + ------- + n : int number of periods to increment + + See Also + -------- + get_day_of_month : Find the day in a month provided an offset. + """ + + months_since = other.month % modby - month % modby + + if n > 0: + if months_since < 0 or ( + months_since == 0 and + other.day < _get_day_of_month(other, day_option)): + # pretend to roll back if on same month but + # before compare_day + n -= 1 + else: + if months_since > 0 or ( + months_since == 0 and + other.day > _get_day_of_month(other, day_option)): + # make sure to roll forward, so negate + n += 1 + return n + + class MonthBegin(BaseCFTimeOffset): _freq = 'MS' def __apply__(self, other): n = _adjust_n_months(other.day, self.n, 1) - return _shift_months(other, n, 'start') + return _shift_month(other, n, 'start') def onOffset(self, date): """Check if the given date is in the set of possible dates created @@ -229,7 +318,7 @@ class MonthEnd(BaseCFTimeOffset): def __apply__(self, other): n = _adjust_n_months(other.day, self.n, _days_in_month(other)) - return _shift_months(other, n, 'end') + return _shift_month(other, n, 'end') def onOffset(self, date): """Check if the given date is in the set of possible dates created @@ -253,6 +342,121 @@ def onOffset(self, date): } +class QuarterOffset(BaseCFTimeOffset): + """Quarter representation copied off of pandas/tseries/offsets.py + """ + _freq = None # type: ClassVar[str] + _default_month = None # type: ClassVar[int] + + def __init__(self, n=1, normalize=False, month=None): + BaseCFTimeOffset.__init__(self, n) + self.normalize = normalize + if month is None: + self.month = self._default_month + else: + self.month = month + if not isinstance(self.month, int): + raise TypeError("'self.month' must be an integer value between 1 " + "and 12. Instead, it was set to a value of " + "{!r}".format(self.month)) + elif not (1 <= self.month <= 12): + raise ValueError("'self.month' must be an integer value between 1 " + "and 12. Instead, it was set to a value of " + "{!r}".format(self.month)) + + def __apply__(self, other): + # months_since: find the calendar quarter containing other.month, + # e.g. if other.month == 8, the calendar quarter is [Jul, Aug, Sep]. + # Then find the month in that quarter containing an onOffset date for + # self. `months_since` is the number of months to shift other.month + # to get to this on-offset month. + months_since = other.month % 3 - self.month % 3 + qtrs = roll_qtrday(other, self.n, self.month, + day_option=self._day_option, modby=3) + months = qtrs * 3 - months_since + return _shift_month(other, months, self._day_option) + + def onOffset(self, date): + """Check if the given date is in the set of possible dates created + using a length-one version of this offset class.""" + if self.normalize and not _is_normalized(date): + return False + mod_month = (date.month - self.month) % 3 + return mod_month == 0 and date.day == self._get_offset_day(date) + + def __sub__(self, other): + import cftime + + if isinstance(other, cftime.datetime): + raise TypeError('Cannot subtract cftime.datetime from offset.') + elif type(other) == type(self) and other.month == self.month: + return type(self)(self.n - other.n, month=self.month) + else: + return NotImplemented + + def __mul__(self, other): + return type(self)(n=other * self.n, month=self.month) + + def rule_code(self): + return '{}-{}'.format(self._freq, _MONTH_ABBREVIATIONS[self.month]) + + def __str__(self): + return '<{}: n={}, month={}>'.format( + type(self).__name__, self.n, self.month) + + +class QuarterBegin(QuarterOffset): + """Default month for QuarterBegin is December + DateOffset increments between Quarter dates. + + month = 1 corresponds to dates like 1/31/2007, 4/30/2007, ... + month = 2 corresponds to dates like 2/28/2007, 5/31/2007, ... + month = 3 corresponds to dates like 3/31/2007, 6/30/2007, ... + """ + # In pandas, _from_name_startingMonth = 1 used when freq='QS' + _default_month = 1 + _freq = 'QS' + _day_option = 'start' + + def rollforward(self, date): + """Roll date forward to nearest start of quarter""" + if self.onOffset(date): + return date + else: + return date + QuarterBegin(month=self.month) + + def rollback(self, date): + """Roll date backward to nearest start of quarter""" + if self.onOffset(date): + return date + else: + return date - QuarterBegin(month=self.month) + + +class QuarterEnd(QuarterOffset): + """Default month for QuarterEnd is December + """ + # In pandas, QuarterOffset._from_name suffix == 'DEC' + # See _lite_rule_alias in pandas._libs.tslibs.frequencies + _default_month = 12 + _freq = 'Q' + _day_option = 'end' + + def rollforward(self, date): + """Roll date forward to nearest end of quarter""" + if self.onOffset(date): + return date + else: + return date + QuarterEnd(month=self.month) + + def rollback(self, date): + """Roll date backward to nearest end of quarter""" + if self.onOffset(date): + return date + else: + return date - QuarterEnd(month=self.month) + + class YearOffset(BaseCFTimeOffset): _freq = None # type: ClassVar[str] _day_option = None # type: ClassVar[str] @@ -282,7 +486,7 @@ def __apply__(self, other): raise ValueError(self._day_option) years = _adjust_n_years(other, self.n, self.month, reference_day) months = years * 12 + (self.month - other.month) - return _shift_months(other, months, self._day_option) + return _shift_month(other, months, self._day_option) def __sub__(self, other): import cftime @@ -388,6 +592,8 @@ def __apply__(self, other): 'AS': YearBegin, 'Y': YearEnd, 'YS': YearBegin, + 'Q': QuarterEnd, + 'QS': QuarterBegin, 'M': MonthEnd, 'MS': MonthBegin, 'D': Day, @@ -418,7 +624,31 @@ def __apply__(self, other): 'A-SEP': partial(YearEnd, month=9), 'A-OCT': partial(YearEnd, month=10), 'A-NOV': partial(YearEnd, month=11), - 'A-DEC': partial(YearEnd, month=12) + 'A-DEC': partial(YearEnd, month=12), + 'QS-JAN': partial(QuarterBegin, month=1), + 'QS-FEB': partial(QuarterBegin, month=2), + 'QS-MAR': partial(QuarterBegin, month=3), + 'QS-APR': partial(QuarterBegin, month=4), + 'QS-MAY': partial(QuarterBegin, month=5), + 'QS-JUN': partial(QuarterBegin, month=6), + 'QS-JUL': partial(QuarterBegin, month=7), + 'QS-AUG': partial(QuarterBegin, month=8), + 'QS-SEP': partial(QuarterBegin, month=9), + 'QS-OCT': partial(QuarterBegin, month=10), + 'QS-NOV': partial(QuarterBegin, month=11), + 'QS-DEC': partial(QuarterBegin, month=12), + 'Q-JAN': partial(QuarterEnd, month=1), + 'Q-FEB': partial(QuarterEnd, month=2), + 'Q-MAR': partial(QuarterEnd, month=3), + 'Q-APR': partial(QuarterEnd, month=4), + 'Q-MAY': partial(QuarterEnd, month=5), + 'Q-JUN': partial(QuarterEnd, month=6), + 'Q-JUL': partial(QuarterEnd, month=7), + 'Q-AUG': partial(QuarterEnd, month=8), + 'Q-SEP': partial(QuarterEnd, month=9), + 'Q-OCT': partial(QuarterEnd, month=10), + 'Q-NOV': partial(QuarterEnd, month=11), + 'Q-DEC': partial(QuarterEnd, month=12) } @@ -607,55 +837,84 @@ def cftime_range(start=None, end=None, periods=None, freq='D', Valid simple frequency strings for use with ``cftime``-calendars include any multiples of the following. - +--------+-----------------------+ - | Alias | Description | - +========+=======================+ - | A, Y | Year-end frequency | - +--------+-----------------------+ - | AS, YS | Year-start frequency | - +--------+-----------------------+ - | M | Month-end frequency | - +--------+-----------------------+ - | MS | Month-start frequency | - +--------+-----------------------+ - | D | Day frequency | - +--------+-----------------------+ - | H | Hour frequency | - +--------+-----------------------+ - | T, min | Minute frequency | - +--------+-----------------------+ - | S | Second frequency | - +--------+-----------------------+ + +--------+--------------------------+ + | Alias | Description | + +========+==========================+ + | A, Y | Year-end frequency | + +--------+--------------------------+ + | AS, YS | Year-start frequency | + +--------+--------------------------+ + | Q | Quarter-end frequency | + +--------+--------------------------+ + | QS | Quarter-start frequency | + +--------+--------------------------+ + | M | Month-end frequency | + +--------+--------------------------+ + | MS | Month-start frequency | + +--------+--------------------------+ + | D | Day frequency | + +--------+--------------------------+ + | H | Hour frequency | + +--------+--------------------------+ + | T, min | Minute frequency | + +--------+--------------------------+ + | S | Second frequency | + +--------+--------------------------+ Any multiples of the following anchored offsets are also supported. - +----------+-------------------------------------------------------------------+ - | Alias | Description | - +==========+===================================================================+ - | A(S)-JAN | Annual frequency, anchored at the end (or beginning) of January | - +----------+-------------------------------------------------------------------+ - | A(S)-FEB | Annual frequency, anchored at the end (or beginning) of February | - +----------+-------------------------------------------------------------------+ - | A(S)-MAR | Annual frequency, anchored at the end (or beginning) of March | - +----------+-------------------------------------------------------------------+ - | A(S)-APR | Annual frequency, anchored at the end (or beginning) of April | - +----------+-------------------------------------------------------------------+ - | A(S)-MAY | Annual frequency, anchored at the end (or beginning) of May | - +----------+-------------------------------------------------------------------+ - | A(S)-JUN | Annual frequency, anchored at the end (or beginning) of June | - +----------+-------------------------------------------------------------------+ - | A(S)-JUL | Annual frequency, anchored at the end (or beginning) of July | - +----------+-------------------------------------------------------------------+ - | A(S)-AUG | Annual frequency, anchored at the end (or beginning) of August | - +----------+-------------------------------------------------------------------+ - | A(S)-SEP | Annual frequency, anchored at the end (or beginning) of September | - +----------+-------------------------------------------------------------------+ - | A(S)-OCT | Annual frequency, anchored at the end (or beginning) of October | - +----------+-------------------------------------------------------------------+ - | A(S)-NOV | Annual frequency, anchored at the end (or beginning) of November | - +----------+-------------------------------------------------------------------+ - | A(S)-DEC | Annual frequency, anchored at the end (or beginning) of December | - +----------+-------------------------------------------------------------------+ + +----------+--------------------------------------------------------------------+ + | Alias | Description | + +==========+====================================================================+ + | A(S)-JAN | Annual frequency, anchored at the end (or beginning) of January | + +----------+--------------------------------------------------------------------+ + | A(S)-FEB | Annual frequency, anchored at the end (or beginning) of February | + +----------+--------------------------------------------------------------------+ + | A(S)-MAR | Annual frequency, anchored at the end (or beginning) of March | + +----------+--------------------------------------------------------------------+ + | A(S)-APR | Annual frequency, anchored at the end (or beginning) of April | + +----------+--------------------------------------------------------------------+ + | A(S)-MAY | Annual frequency, anchored at the end (or beginning) of May | + +----------+--------------------------------------------------------------------+ + | A(S)-JUN | Annual frequency, anchored at the end (or beginning) of June | + +----------+--------------------------------------------------------------------+ + | A(S)-JUL | Annual frequency, anchored at the end (or beginning) of July | + +----------+--------------------------------------------------------------------+ + | A(S)-AUG | Annual frequency, anchored at the end (or beginning) of August | + +----------+--------------------------------------------------------------------+ + | A(S)-SEP | Annual frequency, anchored at the end (or beginning) of September | + +----------+--------------------------------------------------------------------+ + | A(S)-OCT | Annual frequency, anchored at the end (or beginning) of October | + +----------+--------------------------------------------------------------------+ + | A(S)-NOV | Annual frequency, anchored at the end (or beginning) of November | + +----------+--------------------------------------------------------------------+ + | A(S)-DEC | Annual frequency, anchored at the end (or beginning) of December | + +----------+--------------------------------------------------------------------+ + | Q(S)-JAN | Quarter frequency, anchored at the end (or beginning) of January | + +----------+--------------------------------------------------------------------+ + | Q(S)-FEB | Quarter frequency, anchored at the end (or beginning) of February | + +----------+--------------------------------------------------------------------+ + | Q(S)-MAR | Quarter frequency, anchored at the end (or beginning) of January | + +----------+--------------------------------------------------------------------+ + | Q(S)-APR | Quarter frequency, anchored at the end (or beginning) of February | + +----------+--------------------------------------------------------------------+ + | Q(S)-MAY | Quarter frequency, anchored at the end (or beginning) of January | + +----------+--------------------------------------------------------------------+ + | Q(S)-JUN | Quarter frequency, anchored at the end (or beginning) of February | + +----------+--------------------------------------------------------------------+ + | Q(S)-JUL | Quarter frequency, anchored at the end (or beginning) of January | + +----------+--------------------------------------------------------------------+ + | Q(S)-AUG | Quarter frequency, anchored at the end (or beginning) of February | + +----------+--------------------------------------------------------------------+ + | Q(S)-SEP | Quarter frequency, anchored at the end (or beginning) of January | + +----------+--------------------------------------------------------------------+ + | Q(S)-OCT | Quarter frequency, anchored at the end (or beginning) of February | + +----------+--------------------------------------------------------------------+ + | Q(S)-NOV | Quarter frequency, anchored at the end (or beginning) of January | + +----------+--------------------------------------------------------------------+ + | Q(S)-DEC | Quarter frequency, anchored at the end (or beginning) of February | + +----------+--------------------------------------------------------------------+ + Finally, the following calendar aliases are supported. diff --git a/xarray/tests/test_cftime_offsets.py b/xarray/tests/test_cftime_offsets.py index b9d2cf520a8..187209b00ff 100644 --- a/xarray/tests/test_cftime_offsets.py +++ b/xarray/tests/test_cftime_offsets.py @@ -6,9 +6,9 @@ from xarray import CFTimeIndex from xarray.coding.cftime_offsets import ( - _MONTH_ABBREVIATIONS, BaseCFTimeOffset, Day, Hour, Minute, MonthBegin, - MonthEnd, Second, YearBegin, YearEnd, _days_in_month, cftime_range, - get_date_type, to_cftime_datetime, to_offset) + _MONTH_ABBREVIATIONS, BaseCFTimeOffset, Day, Hour, Minute, Second, + MonthBegin, MonthEnd, YearBegin, YearEnd, QuarterBegin, QuarterEnd, + _days_in_month, cftime_range, get_date_type, to_cftime_datetime, to_offset) cftime = pytest.importorskip('cftime') @@ -32,9 +32,13 @@ def calendar(request): [(BaseCFTimeOffset(), 1), (YearBegin(), 1), (YearEnd(), 1), + (QuarterBegin(), 1), + (QuarterEnd(), 1), (BaseCFTimeOffset(n=2), 2), (YearBegin(n=2), 2), - (YearEnd(n=2), 2)], + (YearEnd(n=2), 2), + (QuarterBegin(n=2), 2), + (QuarterEnd(n=2), 2)], ids=_id_func ) def test_cftime_offset_constructor_valid_n(offset, expected_n): @@ -45,7 +49,9 @@ def test_cftime_offset_constructor_valid_n(offset, expected_n): ('offset', 'invalid_n'), [(BaseCFTimeOffset, 1.5), (YearBegin, 1.5), - (YearEnd, 1.5)], + (YearEnd, 1.5), + (QuarterBegin, 1.5), + (QuarterEnd, 1.5)], ids=_id_func ) def test_cftime_offset_constructor_invalid_n(offset, invalid_n): @@ -58,7 +64,11 @@ def test_cftime_offset_constructor_invalid_n(offset, invalid_n): [(YearBegin(), 1), (YearEnd(), 12), (YearBegin(month=5), 5), - (YearEnd(month=5), 5)], + (YearEnd(month=5), 5), + (QuarterBegin(), 1), + (QuarterEnd(), 12), + (QuarterBegin(month=5), 5), + (QuarterEnd(month=5), 5)], ids=_id_func ) def test_year_offset_constructor_valid_month(offset, expected_month): @@ -72,7 +82,13 @@ def test_year_offset_constructor_valid_month(offset, expected_month): (YearBegin, 13, ValueError,), (YearEnd, 13, ValueError), (YearBegin, 1.5, TypeError), - (YearEnd, 1.5, TypeError)], + (YearEnd, 1.5, TypeError), + (QuarterBegin, 0, ValueError), + (QuarterEnd, 0, ValueError), + (QuarterBegin, 1.5, TypeError), + (QuarterEnd, 1.5, TypeError), + (QuarterBegin, 13, ValueError), + (QuarterEnd, 13, ValueError)], ids=_id_func ) def test_year_offset_constructor_invalid_month( @@ -85,7 +101,8 @@ def test_year_offset_constructor_invalid_month( ('offset', 'expected'), [(BaseCFTimeOffset(), None), (MonthBegin(), 'MS'), - (YearBegin(), 'AS-JAN')], + (YearBegin(), 'AS-JAN'), + (QuarterBegin(), 'QS-JAN')], ids=_id_func ) def test_rule_code(offset, expected): @@ -95,7 +112,8 @@ def test_rule_code(offset, expected): @pytest.mark.parametrize( ('offset', 'expected'), [(BaseCFTimeOffset(), ''), - (YearBegin(), '')], + (YearBegin(), ''), + (QuarterBegin(), '')], ids=_id_func ) def test_str_and_repr(offset, expected): @@ -105,7 +123,7 @@ def test_str_and_repr(offset, expected): @pytest.mark.parametrize( 'offset', - [BaseCFTimeOffset(), MonthBegin(), YearBegin()], + [BaseCFTimeOffset(), MonthBegin(), QuarterBegin(), YearBegin()], ids=_id_func ) def test_to_offset_offset_input(offset): @@ -164,7 +182,38 @@ def test_to_offset_annual(month_label, month_int, multiple, offset_str): assert result == expected -@pytest.mark.parametrize('freq', ['Z', '7min2', 'AM', 'M-', 'AS-', '1H1min']) +_QUARTER_OFFSET_TYPES = { + 'Q': QuarterEnd, + 'QS': QuarterBegin +} + + +@pytest.mark.parametrize(('month_int', 'month_label'), + list(_MONTH_ABBREVIATIONS.items()) + [(0, '')]) +@pytest.mark.parametrize('multiple', [None, 2]) +@pytest.mark.parametrize('offset_str', ['QS', 'Q']) +def test_to_offset_quarter(month_label, month_int, multiple, offset_str): + freq = offset_str + offset_type = _QUARTER_OFFSET_TYPES[offset_str] + if month_label: + freq = '-'.join([freq, month_label]) + if multiple: + freq = '{}'.format(multiple) + freq + result = to_offset(freq) + + if multiple and month_int: + expected = offset_type(n=multiple, month=month_int) + elif multiple: + expected = offset_type(n=multiple) + elif month_int: + expected = offset_type(month=month_int) + else: + expected = offset_type() + assert result == expected + + +@pytest.mark.parametrize('freq', ['Z', '7min2', 'AM', 'M-', 'AS-', 'QS-', + '1H1min']) def test_invalid_to_offset_str(freq): with pytest.raises(ValueError): to_offset(freq) @@ -197,13 +246,16 @@ def test_to_cftime_datetime_error_type_error(): _EQ_TESTS_A = [ BaseCFTimeOffset(), YearBegin(), YearEnd(), YearBegin(month=2), - YearEnd(month=2), MonthBegin(), MonthEnd(), Day(), Hour(), Minute(), + YearEnd(month=2), QuarterBegin(), QuarterEnd(), QuarterBegin(month=2), + QuarterEnd(month=2), MonthBegin(), MonthEnd(), Day(), Hour(), Minute(), Second() ] _EQ_TESTS_B = [ BaseCFTimeOffset(n=2), YearBegin(n=2), YearEnd(n=2), - YearBegin(n=2, month=2), YearEnd(n=2, month=2), MonthBegin(n=2), - MonthEnd(n=2), Day(n=2), Hour(n=2), Minute(n=2), Second(n=2) + YearBegin(n=2, month=2), YearEnd(n=2, month=2), QuarterBegin(n=2), + QuarterEnd(n=2), QuarterBegin(n=2, month=2), QuarterEnd(n=2, month=2), + MonthBegin(n=2), MonthEnd(n=2), Day(n=2), Hour(n=2), Minute(n=2), + Second(n=2) ] @@ -216,8 +268,10 @@ def test_neq(a, b): _EQ_TESTS_B_COPY = [ BaseCFTimeOffset(n=2), YearBegin(n=2), YearEnd(n=2), - YearBegin(n=2, month=2), YearEnd(n=2, month=2), MonthBegin(n=2), - MonthEnd(n=2), Day(n=2), Hour(n=2), Minute(n=2), Second(n=2) + YearBegin(n=2, month=2), YearEnd(n=2, month=2), QuarterBegin(n=2), + QuarterEnd(n=2), QuarterBegin(n=2, month=2), QuarterEnd(n=2, month=2), + MonthBegin(n=2), MonthEnd(n=2), Day(n=2), Hour(n=2), Minute(n=2), + Second(n=2) ] @@ -232,6 +286,8 @@ def test_eq(a, b): (BaseCFTimeOffset(), BaseCFTimeOffset(n=3)), (YearEnd(), YearEnd(n=3)), (YearBegin(), YearBegin(n=3)), + (QuarterEnd(), QuarterEnd(n=3)), + (QuarterBegin(), QuarterBegin(n=3)), (MonthEnd(), MonthEnd(n=3)), (MonthBegin(), MonthBegin(n=3)), (Day(), Day(n=3)), @@ -256,6 +312,8 @@ def test_rmul(offset, expected): [(BaseCFTimeOffset(), BaseCFTimeOffset(n=-1)), (YearEnd(), YearEnd(n=-1)), (YearBegin(), YearBegin(n=-1)), + (QuarterEnd(), QuarterEnd(n=-1)), + (QuarterBegin(), QuarterBegin(n=-1)), (MonthEnd(), MonthEnd(n=-1)), (MonthBegin(), MonthBegin(n=-1)), (Day(), Day(n=-1)), @@ -536,6 +594,89 @@ def test_add_year_end_onOffset( assert result == expected +@pytest.mark.parametrize( + ('initial_date_args', 'offset', 'expected_date_args'), + [((1, 1, 1), QuarterBegin(), (1, 4, 1)), + ((1, 1, 1), QuarterBegin(n=2), (1, 7, 1)), + ((1, 1, 1), QuarterBegin(month=2), (1, 2, 1)), + ((1, 1, 7), QuarterBegin(n=2), (1, 7, 1)), + ((2, 2, 1), QuarterBegin(n=-1), (2, 1, 1)), + ((1, 1, 2), QuarterBegin(n=-1), (1, 1, 1)), + ((1, 1, 1, 5, 5, 5, 5), QuarterBegin(), (1, 4, 1, 5, 5, 5, 5)), + ((2, 1, 1, 5, 5, 5, 5), QuarterBegin(n=-1), (1, 10, 1, 5, 5, 5, 5))], + ids=_id_func +) +def test_add_quarter_begin(calendar, initial_date_args, offset, + expected_date_args): + date_type = get_date_type(calendar) + initial = date_type(*initial_date_args) + result = initial + offset + expected = date_type(*expected_date_args) + assert result == expected + + +@pytest.mark.parametrize( + ('initial_date_args', 'offset', 'expected_year_month', + 'expected_sub_day'), + [((1, 1, 1), QuarterEnd(), (1, 3), ()), + ((1, 1, 1), QuarterEnd(n=2), (1, 6), ()), + ((1, 1, 1), QuarterEnd(month=1), (1, 1), ()), + ((2, 3, 1), QuarterEnd(n=-1), (1, 12), ()), + ((1, 3, 1), QuarterEnd(n=-1, month=2), (1, 2), ()), + ((1, 1, 1, 5, 5, 5, 5), QuarterEnd(), (1, 3), (5, 5, 5, 5)), + ((1, 1, 1, 5, 5, 5, 5), QuarterEnd(n=2), (1, 6), (5, 5, 5, 5))], + ids=_id_func +) +def test_add_quarter_end( + calendar, initial_date_args, offset, expected_year_month, + expected_sub_day +): + date_type = get_date_type(calendar) + initial = date_type(*initial_date_args) + result = initial + offset + reference_args = expected_year_month + (1,) + reference = date_type(*reference_args) + + # Here the days at the end of each month varies based on the calendar used + expected_date_args = (expected_year_month + + (_days_in_month(reference),) + expected_sub_day) + expected = date_type(*expected_date_args) + assert result == expected + + +@pytest.mark.parametrize( + ('initial_year_month', 'initial_sub_day', 'offset', 'expected_year_month', + 'expected_sub_day'), + [((1, 12), (), QuarterEnd(), (2, 3), ()), + ((1, 12), (), QuarterEnd(n=2), (2, 6), ()), + ((1, 12), (), QuarterEnd(n=-1), (1, 9), ()), + ((1, 12), (), QuarterEnd(n=-2), (1, 6), ()), + ((1, 1), (), QuarterEnd(month=2), (1, 2), ()), + ((1, 12), (5, 5, 5, 5), QuarterEnd(), (2, 3), (5, 5, 5, 5)), + ((1, 12), (5, 5, 5, 5), QuarterEnd(n=-1), (1, 9), (5, 5, 5, 5))], + ids=_id_func +) +def test_add_quarter_end_onOffset( + calendar, initial_year_month, initial_sub_day, offset, expected_year_month, + expected_sub_day +): + date_type = get_date_type(calendar) + reference_args = initial_year_month + (1,) + reference = date_type(*reference_args) + initial_date_args = (initial_year_month + (_days_in_month(reference),) + + initial_sub_day) + initial = date_type(*initial_date_args) + result = initial + offset + reference_args = expected_year_month + (1,) + reference = date_type(*reference_args) + + # Here the days at the end of each month varies based on the calendar used + expected_date_args = (expected_year_month + + (_days_in_month(reference),) + expected_sub_day) + expected = date_type(*expected_date_args) + assert result == expected + + # Note for all sub-monthly offsets, pandas always returns True for onOffset @pytest.mark.parametrize( ('date_args', 'offset', 'expected'), @@ -543,6 +684,10 @@ def test_add_year_end_onOffset( ((1, 1, 1, 1), MonthBegin(), True), ((1, 1, 5), MonthBegin(), False), ((1, 1, 5), MonthEnd(), False), + ((1, 1, 1), QuarterBegin(), True), + ((1, 1, 1, 1), QuarterBegin(), True), + ((1, 1, 5), QuarterBegin(), False), + ((1, 12, 1), QuarterEnd(), False), ((1, 1, 1), YearBegin(), True), ((1, 1, 1, 1), YearBegin(), True), ((1, 1, 5), YearBegin(), False), @@ -562,19 +707,22 @@ def test_onOffset(calendar, date_args, offset, expected): @pytest.mark.parametrize( - ('year_month_args', 'sub_day_args', 'offset'), + ('year_quarter_month_args', 'sub_day_args', 'offset'), [((1, 1), (), MonthEnd()), ((1, 1), (1,), MonthEnd()), + ((1, 12), (), QuarterEnd()), + ((1, 1), (), QuarterEnd(month=1)), ((1, 12), (), YearEnd()), ((1, 1), (), YearEnd(month=1))], ids=_id_func ) -def test_onOffset_month_or_year_end( - calendar, year_month_args, sub_day_args, offset): +def test_onOffset_month_or_quarter_or_year_end( + calendar, year_quarter_month_args, sub_day_args, offset): date_type = get_date_type(calendar) - reference_args = year_month_args + (1,) + reference_args = year_quarter_month_args + (1,) reference = date_type(*reference_args) - date_args = year_month_args + (_days_in_month(reference),) + sub_day_args + date_args = (year_quarter_month_args + (_days_in_month(reference),) + + sub_day_args) date = date_type(*date_args) result = offset.onOffset(date) assert result @@ -590,6 +738,14 @@ def test_onOffset_month_or_year_end( (YearEnd(n=2), (1, 3, 1), (1, 12)), (YearEnd(n=2, month=2), (1, 3, 1), (2, 2)), (YearEnd(n=2, month=4), (1, 4, 30), (1, 4)), + (QuarterBegin(), (1, 3, 2), (1, 4)), + (QuarterBegin(), (1, 4, 1), (1, 4)), + (QuarterBegin(n=2), (1, 4, 1), (1, 4)), + (QuarterBegin(n=2, month=2), (1, 4, 1), (1, 5)), + (QuarterEnd(), (1, 3, 1), (1, 3)), + (QuarterEnd(n=2), (1, 3, 1), (1, 3)), + (QuarterEnd(n=2, month=2), (1, 3, 1), (1, 5)), + (QuarterEnd(n=2, month=4), (1, 4, 30), (1, 4)), (MonthBegin(), (1, 3, 2), (1, 4)), (MonthBegin(), (1, 3, 1), (1, 3)), (MonthBegin(n=2), (1, 3, 2), (1, 4)), @@ -606,9 +762,9 @@ def test_rollforward(calendar, offset, initial_date_args, partial_expected_date_args): date_type = get_date_type(calendar) initial = date_type(*initial_date_args) - if isinstance(offset, (MonthBegin, YearBegin)): + if isinstance(offset, (MonthBegin, QuarterBegin, YearBegin)): expected_date_args = partial_expected_date_args + (1,) - elif isinstance(offset, (MonthEnd, YearEnd)): + elif isinstance(offset, (MonthEnd, QuarterEnd, YearEnd)): reference_args = partial_expected_date_args + (1,) reference = date_type(*reference_args) expected_date_args = (partial_expected_date_args + @@ -631,6 +787,14 @@ def test_rollforward(calendar, offset, initial_date_args, (YearEnd(n=2), (2, 3, 1), (1, 12)), (YearEnd(n=2, month=2), (2, 3, 1), (2, 2)), (YearEnd(month=4), (1, 4, 30), (1, 4)), + (QuarterBegin(), (1, 3, 2), (1, 1)), + (QuarterBegin(), (1, 4, 1), (1, 4)), + (QuarterBegin(n=2), (1, 4, 1), (1, 4)), + (QuarterBegin(n=2, month=2), (1, 4, 1), (1, 2)), + (QuarterEnd(), (2, 3, 1), (1, 12)), + (QuarterEnd(n=2), (2, 3, 1), (1, 12)), + (QuarterEnd(n=2, month=2), (2, 3, 1), (2, 2)), + (QuarterEnd(n=2, month=4), (1, 4, 30), (1, 4)), (MonthBegin(), (1, 3, 2), (1, 3)), (MonthBegin(n=2), (1, 3, 2), (1, 3)), (MonthBegin(), (1, 3, 1), (1, 3)), @@ -647,9 +811,9 @@ def test_rollback(calendar, offset, initial_date_args, partial_expected_date_args): date_type = get_date_type(calendar) initial = date_type(*initial_date_args) - if isinstance(offset, (MonthBegin, YearBegin)): + if isinstance(offset, (MonthBegin, QuarterBegin, YearBegin)): expected_date_args = partial_expected_date_args + (1,) - elif isinstance(offset, (MonthEnd, YearEnd)): + elif isinstance(offset, (MonthEnd, QuarterEnd, YearEnd)): reference_args = partial_expected_date_args + (1,) reference = date_type(*reference_args) expected_date_args = (partial_expected_date_args + From ae82957901a93cb385549212b55dc2486c882665 Mon Sep 17 00:00:00 2001 From: Low Date: Sat, 2 Feb 2019 14:46:11 -0500 Subject: [PATCH 02/10] Fixed issues raised in review (https://github.com/pydata/xarray/pull/2721#pullrequestreview-199346642) --- xarray/coding/cftime_offsets.py | 90 ++++++++++++----------------- xarray/tests/test_cftime_offsets.py | 65 ++++++++++++--------- 2 files changed, 76 insertions(+), 79 deletions(-) diff --git a/xarray/coding/cftime_offsets.py b/xarray/coding/cftime_offsets.py index 0b83af89707..6023807e30c 100644 --- a/xarray/coding/cftime_offsets.py +++ b/xarray/coding/cftime_offsets.py @@ -166,13 +166,13 @@ def _is_normalized(datetime): def _get_day_of_month(other, day_option): - """Find the day in `other`'s month that satisfies a DateOffset's onOffset - policy, as described by the `day_opt` argument. + """Find the day in `other`'s month that satisfies a BaseCFTimeOffset's + onOffset policy, as described by the `day_option` argument. Parameters ---------- other : cftime.datetime - day_option : 'start', 'end', or int + day_option : 'start', 'end' 'start': returns 1 'end': returns last day of the month int: returns the day in the month indicated by `other`, or the last of @@ -189,11 +189,8 @@ def _get_day_of_month(other, day_option): elif day_option == 'end': days_in_month = _days_in_month(other) return days_in_month - elif isinstance(day_option, np.integer): - days_in_month = _days_in_month(other) - return min(day_option, days_in_month) elif day_option is None: - # Note: unlike `_shift_month`, get_day_of_month does not + # Note: unlike `_shift_month`, _get_day_of_month does not # allow day_option = None raise NotImplementedError else: @@ -268,7 +265,7 @@ def roll_qtrday(other, n, month, day_option, modby=3): other : cftime.datetime n : number of periods to increment, before adjusting for rolling month : int reference month giving the first month of the year - day_option : 'start', 'end', 'business_start', 'business_end', or int + day_option : 'start', 'end' The convention to use in finding the day in a given month against which to compare for rollforward/rollbackward decisions. modby : int 3 for quarters, 12 for years @@ -300,6 +297,22 @@ def roll_qtrday(other, n, month, day_option, modby=3): return n +def _validate_month(month, default_month): + if month is None: + result_month = default_month + else: + result_month = month + if not isinstance(result_month, int): + raise TypeError("'self.month' must be an integer value between 1 " + "and 12. Instead, it was set to a value of " + "{!r}".format(result_month)) + elif not (1 <= result_month <= 12): + raise ValueError("'self.month' must be an integer value between 1 " + "and 12. Instead, it was set to a value of " + "{!r}".format(result_month)) + return result_month + + class MonthBegin(BaseCFTimeOffset): _freq = 'MS' @@ -351,18 +364,7 @@ class QuarterOffset(BaseCFTimeOffset): def __init__(self, n=1, normalize=False, month=None): BaseCFTimeOffset.__init__(self, n) self.normalize = normalize - if month is None: - self.month = self._default_month - else: - self.month = month - if not isinstance(self.month, int): - raise TypeError("'self.month' must be an integer value between 1 " - "and 12. Instead, it was set to a value of " - "{!r}".format(self.month)) - elif not (1 <= self.month <= 12): - raise ValueError("'self.month' must be an integer value between 1 " - "and 12. Instead, it was set to a value of " - "{!r}".format(self.month)) + self.month = _validate_month(month, self._default_month) def __apply__(self, other): # months_since: find the calendar quarter containing other.month, @@ -414,7 +416,7 @@ class QuarterBegin(QuarterOffset): month = 3 corresponds to dates like 3/31/2007, 6/30/2007, ... """ # In pandas, _from_name_startingMonth = 1 used when freq='QS' - _default_month = 1 + _default_month = 3 _freq = 'QS' _day_option = 'start' @@ -438,7 +440,7 @@ class QuarterEnd(QuarterOffset): """ # In pandas, QuarterOffset._from_name suffix == 'DEC' # See _lite_rule_alias in pandas._libs.tslibs.frequencies - _default_month = 12 + _default_month = 3 _freq = 'Q' _day_option = 'end' @@ -464,26 +466,10 @@ class YearOffset(BaseCFTimeOffset): def __init__(self, n=1, month=None): BaseCFTimeOffset.__init__(self, n) - if month is None: - self.month = self._default_month - else: - self.month = month - if not isinstance(self.month, int): - raise TypeError("'self.month' must be an integer value between 1 " - "and 12. Instead, it was set to a value of " - "{!r}".format(self.month)) - elif not (1 <= self.month <= 12): - raise ValueError("'self.month' must be an integer value between 1 " - "and 12. Instead, it was set to a value of " - "{!r}".format(self.month)) + self.month = _validate_month(month, self._default_month) def __apply__(self, other): - if self._day_option == 'start': - reference_day = 1 - elif self._day_option == 'end': - reference_day = _days_in_month(other) - else: - raise ValueError(self._day_option) + reference_day = _get_day_of_month(other, self._day_option) years = _adjust_n_years(other, self.n, self.month, reference_day) months = years * 12 + (self.month - other.month) return _shift_month(other, months, self._day_option) @@ -592,8 +578,8 @@ def __apply__(self, other): 'AS': YearBegin, 'Y': YearEnd, 'YS': YearBegin, - 'Q': QuarterEnd, - 'QS': QuarterBegin, + 'Q': partial(QuarterEnd, month=12), + 'QS': partial(QuarterBegin, month=1), 'M': MonthEnd, 'MS': MonthBegin, 'D': Day, @@ -894,25 +880,25 @@ def cftime_range(start=None, end=None, periods=None, freq='D', +----------+--------------------------------------------------------------------+ | Q(S)-FEB | Quarter frequency, anchored at the end (or beginning) of February | +----------+--------------------------------------------------------------------+ - | Q(S)-MAR | Quarter frequency, anchored at the end (or beginning) of January | + | Q(S)-MAR | Quarter frequency, anchored at the end (or beginning) of March | +----------+--------------------------------------------------------------------+ - | Q(S)-APR | Quarter frequency, anchored at the end (or beginning) of February | + | Q(S)-APR | Quarter frequency, anchored at the end (or beginning) of April | +----------+--------------------------------------------------------------------+ - | Q(S)-MAY | Quarter frequency, anchored at the end (or beginning) of January | + | Q(S)-MAY | Quarter frequency, anchored at the end (or beginning) of May | +----------+--------------------------------------------------------------------+ - | Q(S)-JUN | Quarter frequency, anchored at the end (or beginning) of February | + | Q(S)-JUN | Quarter frequency, anchored at the end (or beginning) of June | +----------+--------------------------------------------------------------------+ - | Q(S)-JUL | Quarter frequency, anchored at the end (or beginning) of January | + | Q(S)-JUL | Quarter frequency, anchored at the end (or beginning) of July | +----------+--------------------------------------------------------------------+ - | Q(S)-AUG | Quarter frequency, anchored at the end (or beginning) of February | + | Q(S)-AUG | Quarter frequency, anchored at the end (or beginning) of August | +----------+--------------------------------------------------------------------+ - | Q(S)-SEP | Quarter frequency, anchored at the end (or beginning) of January | + | Q(S)-SEP | Quarter frequency, anchored at the end (or beginning) of September | +----------+--------------------------------------------------------------------+ - | Q(S)-OCT | Quarter frequency, anchored at the end (or beginning) of February | + | Q(S)-OCT | Quarter frequency, anchored at the end (or beginning) of October | +----------+--------------------------------------------------------------------+ - | Q(S)-NOV | Quarter frequency, anchored at the end (or beginning) of January | + | Q(S)-NOV | Quarter frequency, anchored at the end (or beginning) of November | +----------+--------------------------------------------------------------------+ - | Q(S)-DEC | Quarter frequency, anchored at the end (or beginning) of February | + | Q(S)-DEC | Quarter frequency, anchored at the end (or beginning) of December | +----------+--------------------------------------------------------------------+ diff --git a/xarray/tests/test_cftime_offsets.py b/xarray/tests/test_cftime_offsets.py index 187209b00ff..49e339fcd39 100644 --- a/xarray/tests/test_cftime_offsets.py +++ b/xarray/tests/test_cftime_offsets.py @@ -65,8 +65,8 @@ def test_cftime_offset_constructor_invalid_n(offset, invalid_n): (YearEnd(), 12), (YearBegin(month=5), 5), (YearEnd(month=5), 5), - (QuarterBegin(), 1), - (QuarterEnd(), 12), + (QuarterBegin(), 3), + (QuarterEnd(), 3), (QuarterBegin(month=5), 5), (QuarterEnd(month=5), 5)], ids=_id_func @@ -102,7 +102,7 @@ def test_year_offset_constructor_invalid_month( [(BaseCFTimeOffset(), None), (MonthBegin(), 'MS'), (YearBegin(), 'AS-JAN'), - (QuarterBegin(), 'QS-JAN')], + (QuarterBegin(), 'QS-MAR')], ids=_id_func ) def test_rule_code(offset, expected): @@ -113,7 +113,7 @@ def test_rule_code(offset, expected): ('offset', 'expected'), [(BaseCFTimeOffset(), ''), (YearBegin(), ''), - (QuarterBegin(), '')], + (QuarterBegin(), '')], ids=_id_func ) def test_str_and_repr(offset, expected): @@ -204,11 +204,20 @@ def test_to_offset_quarter(month_label, month_int, multiple, offset_str): if multiple and month_int: expected = offset_type(n=multiple, month=month_int) elif multiple: - expected = offset_type(n=multiple) + if month_int: + expected = offset_type(n=multiple) + else: + if offset_type == QuarterBegin: + expected = offset_type(n=multiple, month=1) + elif offset_type == QuarterEnd: + expected = offset_type(n=multiple, month=12) elif month_int: expected = offset_type(month=month_int) else: - expected = offset_type() + if offset_type == QuarterBegin: + expected = offset_type(month=1) + elif offset_type == QuarterEnd: + expected = offset_type(month=12) assert result == expected @@ -596,14 +605,14 @@ def test_add_year_end_onOffset( @pytest.mark.parametrize( ('initial_date_args', 'offset', 'expected_date_args'), - [((1, 1, 1), QuarterBegin(), (1, 4, 1)), - ((1, 1, 1), QuarterBegin(n=2), (1, 7, 1)), + [((1, 1, 1), QuarterBegin(), (1, 3, 1)), + ((1, 1, 1), QuarterBegin(n=2), (1, 6, 1)), ((1, 1, 1), QuarterBegin(month=2), (1, 2, 1)), - ((1, 1, 7), QuarterBegin(n=2), (1, 7, 1)), - ((2, 2, 1), QuarterBegin(n=-1), (2, 1, 1)), - ((1, 1, 2), QuarterBegin(n=-1), (1, 1, 1)), - ((1, 1, 1, 5, 5, 5, 5), QuarterBegin(), (1, 4, 1, 5, 5, 5, 5)), - ((2, 1, 1, 5, 5, 5, 5), QuarterBegin(n=-1), (1, 10, 1, 5, 5, 5, 5))], + ((1, 1, 7), QuarterBegin(n=2), (1, 6, 1)), + ((2, 2, 1), QuarterBegin(n=-1), (1, 12, 1)), + ((1, 3, 2), QuarterBegin(n=-1), (1, 3, 1)), + ((1, 1, 1, 5, 5, 5, 5), QuarterBegin(), (1, 3, 1, 5, 5, 5, 5)), + ((2, 1, 1, 5, 5, 5, 5), QuarterBegin(n=-1), (1, 12, 1, 5, 5, 5, 5))], ids=_id_func ) def test_add_quarter_begin(calendar, initial_date_args, offset, @@ -684,9 +693,9 @@ def test_add_quarter_end_onOffset( ((1, 1, 1, 1), MonthBegin(), True), ((1, 1, 5), MonthBegin(), False), ((1, 1, 5), MonthEnd(), False), - ((1, 1, 1), QuarterBegin(), True), - ((1, 1, 1, 1), QuarterBegin(), True), - ((1, 1, 5), QuarterBegin(), False), + ((1, 3, 1), QuarterBegin(), True), + ((1, 3, 1, 1), QuarterBegin(), True), + ((1, 3, 5), QuarterBegin(), False), ((1, 12, 1), QuarterEnd(), False), ((1, 1, 1), YearBegin(), True), ((1, 1, 1, 1), YearBegin(), True), @@ -707,7 +716,7 @@ def test_onOffset(calendar, date_args, offset, expected): @pytest.mark.parametrize( - ('year_quarter_month_args', 'sub_day_args', 'offset'), + ('year_month_args', 'sub_day_args', 'offset'), [((1, 1), (), MonthEnd()), ((1, 1), (1,), MonthEnd()), ((1, 12), (), QuarterEnd()), @@ -717,11 +726,11 @@ def test_onOffset(calendar, date_args, offset, expected): ids=_id_func ) def test_onOffset_month_or_quarter_or_year_end( - calendar, year_quarter_month_args, sub_day_args, offset): + calendar, year_month_args, sub_day_args, offset): date_type = get_date_type(calendar) - reference_args = year_quarter_month_args + (1,) + reference_args = year_month_args + (1,) reference = date_type(*reference_args) - date_args = (year_quarter_month_args + (_days_in_month(reference),) + + date_args = (year_month_args + (_days_in_month(reference),) + sub_day_args) date = date_type(*date_args) result = offset.onOffset(date) @@ -738,9 +747,9 @@ def test_onOffset_month_or_quarter_or_year_end( (YearEnd(n=2), (1, 3, 1), (1, 12)), (YearEnd(n=2, month=2), (1, 3, 1), (2, 2)), (YearEnd(n=2, month=4), (1, 4, 30), (1, 4)), - (QuarterBegin(), (1, 3, 2), (1, 4)), - (QuarterBegin(), (1, 4, 1), (1, 4)), - (QuarterBegin(n=2), (1, 4, 1), (1, 4)), + (QuarterBegin(), (1, 3, 2), (1, 6)), + (QuarterBegin(), (1, 4, 1), (1, 6)), + (QuarterBegin(n=2), (1, 4, 1), (1, 6)), (QuarterBegin(n=2, month=2), (1, 4, 1), (1, 5)), (QuarterEnd(), (1, 3, 1), (1, 3)), (QuarterEnd(n=2), (1, 3, 1), (1, 3)), @@ -787,9 +796,9 @@ def test_rollforward(calendar, offset, initial_date_args, (YearEnd(n=2), (2, 3, 1), (1, 12)), (YearEnd(n=2, month=2), (2, 3, 1), (2, 2)), (YearEnd(month=4), (1, 4, 30), (1, 4)), - (QuarterBegin(), (1, 3, 2), (1, 1)), - (QuarterBegin(), (1, 4, 1), (1, 4)), - (QuarterBegin(n=2), (1, 4, 1), (1, 4)), + (QuarterBegin(), (1, 3, 2), (1, 3)), + (QuarterBegin(), (1, 4, 1), (1, 3)), + (QuarterBegin(n=2), (1, 4, 1), (1, 3)), (QuarterBegin(n=2, month=2), (1, 4, 1), (1, 2)), (QuarterEnd(), (2, 3, 1), (1, 12)), (QuarterEnd(n=2), (2, 3, 1), (1, 12)), @@ -851,7 +860,9 @@ def test_rollback(calendar, offset, initial_date_args, ('0010', None, 4, YearBegin(n=-2), None, False, [(10, 1, 1), (8, 1, 1), (6, 1, 1), (4, 1, 1)]), ('0001-01-01', '0001-01-04', 4, None, None, False, - [(1, 1, 1), (1, 1, 2), (1, 1, 3), (1, 1, 4)]) + [(1, 1, 1), (1, 1, 2), (1, 1, 3), (1, 1, 4)]), + ('0001-06-01', None, 4, '3QS-JUN', None, False, + [(1, 6, 1), (2, 3, 1), (2, 12, 1), (3, 9, 1)]) ] From 87c969078338e6e3642d46b194c3635816da16d8 Mon Sep 17 00:00:00 2001 From: Low Date: Sat, 2 Feb 2019 14:57:10 -0500 Subject: [PATCH 03/10] Updated whats-new.rst with info on quarter offset support. --- doc/whats-new.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 184cee05ae2..b5b4d666305 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -43,6 +43,8 @@ Enhancements report showing what exactly differs between the two objects (dimensions / coordinates / variables / attributes) (:issue:`1507`). By `Benoit Bovy `_. +- CFTimeIndex now supports QuarterBegin and QuarterEnd offsets. + By `Jwen Fai Low `_ Bug fixes ~~~~~~~~~ From 3ab3b3c41daf24ec2f9f3375d77eb1c9e151ef7b Mon Sep 17 00:00:00 2001 From: Low Date: Sat, 2 Feb 2019 15:04:45 -0500 Subject: [PATCH 04/10] Updated whats-new.rst with info on quarter offset support. --- doc/whats-new.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index b5b4d666305..29b31f83c61 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -43,7 +43,7 @@ Enhancements report showing what exactly differs between the two objects (dimensions / coordinates / variables / attributes) (:issue:`1507`). By `Benoit Bovy `_. -- CFTimeIndex now supports QuarterBegin and QuarterEnd offsets. +- CFTimeIndex now supports QuarterBegin and QuarterEnd offsets. (:issue:`2663`) By `Jwen Fai Low `_ Bug fixes From 46e95406dee0ba323e3e4b1bd5ee5a157b8fbebb Mon Sep 17 00:00:00 2001 From: Spencer Clark Date: Sat, 2 Feb 2019 18:45:46 -0500 Subject: [PATCH 05/10] Update doc/whats-new.rst Co-Authored-By: jwenfai --- doc/whats-new.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 07d96bedc68..2c02e4f2f93 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -58,7 +58,7 @@ Enhancements - :py:meth:`pandas.Series.dropna` is now supported for a :py:class:`pandas.Series` indexed by a :py:class:`~xarray.CFTimeIndex` (:issue:`2688`). By `Spencer Clark `_. -- CFTimeIndex now supports QuarterBegin and QuarterEnd offsets. +- :py:meth:`~xarray.cftime_range` now supports QuarterBegin and QuarterEnd offsets (:issue:`2663`). By `Jwen Fai Low `_ Bug fixes From 39386fa6a19ad9a8e70a04e24e77447958a84c1e Mon Sep 17 00:00:00 2001 From: Low Date: Sat, 2 Feb 2019 23:30:57 -0500 Subject: [PATCH 06/10] Added support for quarter frequencies when resampling CFTimeIndex. Less redundancy in CFTimeIndex resampling tests. --- xarray/core/resample_cftime.py | 8 +-- xarray/tests/test_cftimeindex_resample.py | 62 +++++++++++++++++++---- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/xarray/core/resample_cftime.py b/xarray/core/resample_cftime.py index 6b6d214768e..161945f118d 100644 --- a/xarray/core/resample_cftime.py +++ b/xarray/core/resample_cftime.py @@ -38,7 +38,7 @@ from ..coding.cftimeindex import CFTimeIndex from ..coding.cftime_offsets import (cftime_range, normalize_date, - Day, MonthEnd, YearEnd, + Day, MonthEnd, QuarterEnd, YearEnd, CFTIME_TICKS, to_offset) import datetime import numpy as np @@ -50,14 +50,14 @@ class CFTimeGrouper(object): single method, the only one required for resampling in xarray. It cannot be used in a call to groupby like a pandas.Grouper object can.""" - def __init__(self, freq, closed, label, base, loffset): + def __init__(self, freq, closed=None, label=None, base=0, loffset=None): self.freq = to_offset(freq) self.closed = closed self.label = label self.base = base self.loffset = loffset - if isinstance(self.freq, (MonthEnd, YearEnd)): + if isinstance(self.freq, (MonthEnd, QuarterEnd, YearEnd)): if self.closed is None: self.closed = 'right' if self.label is None: @@ -199,7 +199,7 @@ def _adjust_bin_edges(datetime_bins, offset, closed, index, labels): This is also required for daily frequencies longer than one day and year-end frequencies. """ - is_super_daily = (isinstance(offset, (MonthEnd, YearEnd)) or + is_super_daily = (isinstance(offset, (MonthEnd, QuarterEnd, YearEnd)) or (isinstance(offset, Day) and offset.n > 1)) if is_super_daily: if closed == 'right': diff --git a/xarray/tests/test_cftimeindex_resample.py b/xarray/tests/test_cftimeindex_resample.py index 0b56f1d1fc6..a44a9be0f39 100644 --- a/xarray/tests/test_cftimeindex_resample.py +++ b/xarray/tests/test_cftimeindex_resample.py @@ -4,6 +4,7 @@ import numpy as np import pandas as pd import xarray as xr +from xarray.core.resample_cftime import CFTimeGrouper pytest.importorskip('cftime') pytest.importorskip('pandas', minversion='0.24') @@ -13,10 +14,10 @@ params=[ dict(start='2004-01-01T12:07:01', periods=91, freq='3D'), dict(start='1892-01-03T12:07:01', periods=15, freq='41987T'), - dict(start='2004-01-01T12:07:01', periods=31, freq='2MS'), + dict(start='2004-01-01T12:07:01', periods=7, freq='3Q-AUG'), dict(start='1892-01-03T12:07:01', periods=10, freq='3AS-JUN') ], - ids=['3D', '41987T', '2MS', '3AS_JUN'] + ids=['3D', '41987T', '3Q_AUG', '3AS_JUN'] ) def time_range_kwargs(request): return request.param @@ -40,15 +41,18 @@ def da(index): @pytest.mark.parametrize('freq', [ '700T', '8001T', '12H', '8001H', - '3D', '8D', '8001D', - '2MS', '2M', '3MS', '3M', '4MS', '4M', - '3AS', '3A', '4AS', '4A']) -@pytest.mark.parametrize('closed', [None, 'left', 'right']) -@pytest.mark.parametrize('label', [None, 'left', 'right']) -@pytest.mark.parametrize('base', [17, 24]) + '8D', '8001D', + '2MS', '3MS', + '2QS-AUG', '3QS-SEP', + '3AS-MAR', '4A-MAY']) +@pytest.mark.parametrize('closed', [None, 'right']) +@pytest.mark.parametrize('label', [None, 'right']) +@pytest.mark.parametrize('base', [12, 31]) def test_resampler(freq, closed, label, base, datetime_index, cftime_index): # Fairly extensive testing for standard/proleptic Gregorian calendar + # For any frequencies which are not greater-than-day and anchored + # at the end, the default values for closed and label are 'left'. loffset = '12H' try: da_datetime = da(datetime_index).resample( @@ -67,11 +71,51 @@ def test_resampler(freq, closed, label, base, xr.testing.assert_identical(da_cftime, da_datetime) +@pytest.mark.parametrize('freq', [ + '2M', '3M', + '2Q-JUN', '3Q-JUL', + '3A-FEB', '4A-APR']) +@pytest.mark.parametrize('closed', ['left', None]) +@pytest.mark.parametrize('label', ['left', None]) +@pytest.mark.parametrize('base', [17, 24]) +def test_resampler_end_super_day(freq, closed, label, base, + datetime_index, cftime_index): + # Fairly extensive testing for standard/proleptic Gregorian calendar. + # For greater-than-day frequencies anchored at the end, the default values + # for closed and label are 'right'. + loffset = '12H' + try: + da_datetime = da(datetime_index).resample( + time=freq, closed=closed, label=label, base=base, + loffset=loffset).mean() + except ValueError: + with pytest.raises(ValueError): + da(cftime_index).resample( + time=freq, closed=closed, label=label, base=base, + loffset=loffset).mean() + else: + da_cftime = da(cftime_index).resample(time=freq, closed=closed, + label=label, base=base, + loffset=loffset).mean() + da_cftime['time'] = da_cftime.indexes['time'].to_datetimeindex() + xr.testing.assert_identical(da_cftime, da_datetime) + + +@pytest.mark.parametrize( + ('freq', 'expected'), + [('S', 'left'), ('T', 'left'), ('H', 'left'), ('D', 'left'), + ('M', 'right'), ('MS', 'left'), ('Q', 'right'), ('QS', 'left'), + ('A', 'right'), ('AS', 'left')]) +def test_closed_label_defaults(freq, expected): + assert CFTimeGrouper(freq=freq).closed == expected + assert CFTimeGrouper(freq=freq).label == expected + + @pytest.mark.parametrize('calendar', ['gregorian', 'noleap', 'all_leap', '360_day', 'julian']) def test_calendars(calendar): # Limited testing for non-standard calendars - freq, closed, label, base = '81T', None, None, 17 + freq, closed, label, base = '8001T', None, None, 17 loffset = datetime.timedelta(hours=12) xr_index = xr.cftime_range(start='2004-01-01T12:07:01', periods=7, freq='3D', calendar=calendar) From 0d086a000f41cad4bacc4038046e827f7ece377f Mon Sep 17 00:00:00 2001 From: Low Date: Sun, 3 Feb 2019 13:57:49 -0500 Subject: [PATCH 07/10] Removed normalization code (unnecessary for cftime_range) in cftime_offsets.py. Removed redundant lines in whats-new.rst. --- doc/whats-new.rst | 2 -- xarray/coding/cftime_offsets.py | 12 +----------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 8bd33ab6353..b602d7df16d 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -47,8 +47,6 @@ Enhancements report showing what exactly differs between the two objects (dimensions / coordinates / variables / attributes) (:issue:`1507`). By `Benoit Bovy `_. -- CFTimeIndex now supports QuarterBegin and QuarterEnd offsets. (:issue:`2663`) - By `Jwen Fai Low `_ - Resampling of standard and non-standard calendars indexed by :py:class:`~xarray.CFTimeIndex` is now possible. (:issue:`2191`). By `Jwen Fai Low `_ and diff --git a/xarray/coding/cftime_offsets.py b/xarray/coding/cftime_offsets.py index 5511d63e67a..a1b88f5f856 100644 --- a/xarray/coding/cftime_offsets.py +++ b/xarray/coding/cftime_offsets.py @@ -158,13 +158,6 @@ def _get_offset_day(self, other): return _get_day_of_month(other, self._day_option) -def _is_normalized(datetime): - if (datetime.hour != 0 or datetime.minute != 0 or datetime.second != 0 or - datetime.microsecond != 0): - return False - return True - - def _get_day_of_month(other, day_option): """Find the day in `other`'s month that satisfies a BaseCFTimeOffset's onOffset policy, as described by the `day_option` argument. @@ -361,9 +354,8 @@ class QuarterOffset(BaseCFTimeOffset): _freq = None # type: ClassVar[str] _default_month = None # type: ClassVar[int] - def __init__(self, n=1, normalize=False, month=None): + def __init__(self, n=1, month=None): BaseCFTimeOffset.__init__(self, n) - self.normalize = normalize self.month = _validate_month(month, self._default_month) def __apply__(self, other): @@ -381,8 +373,6 @@ def __apply__(self, other): def onOffset(self, date): """Check if the given date is in the set of possible dates created using a length-one version of this offset class.""" - if self.normalize and not _is_normalized(date): - return False mod_month = (date.month - self.month) % 3 return mod_month == 0 and date.day == self._get_offset_day(date) From 2d0e2b1f901ae3cc9a3da8293a3a4a82bb1b70a2 Mon Sep 17 00:00:00 2001 From: Low Date: Mon, 4 Feb 2019 14:44:40 -0500 Subject: [PATCH 08/10] Removed invalid option from _get_day_of_month docstring. Added tests back in that raises ValueError when resampling (base=24 when resampling to daily freq, e.g., '8D'). --- xarray/coding/cftime_offsets.py | 2 -- xarray/tests/test_cftimeindex_resample.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/xarray/coding/cftime_offsets.py b/xarray/coding/cftime_offsets.py index a1b88f5f856..56529029f15 100644 --- a/xarray/coding/cftime_offsets.py +++ b/xarray/coding/cftime_offsets.py @@ -168,8 +168,6 @@ def _get_day_of_month(other, day_option): day_option : 'start', 'end' 'start': returns 1 'end': returns last day of the month - int: returns the day in the month indicated by `other`, or the last of - day the month if the value exceeds in that month's number of days. Returns ------- diff --git a/xarray/tests/test_cftimeindex_resample.py b/xarray/tests/test_cftimeindex_resample.py index a44a9be0f39..636f9ef7b0e 100644 --- a/xarray/tests/test_cftimeindex_resample.py +++ b/xarray/tests/test_cftimeindex_resample.py @@ -47,7 +47,7 @@ def da(index): '3AS-MAR', '4A-MAY']) @pytest.mark.parametrize('closed', [None, 'right']) @pytest.mark.parametrize('label', [None, 'right']) -@pytest.mark.parametrize('base', [12, 31]) +@pytest.mark.parametrize('base', [24, 31]) def test_resampler(freq, closed, label, base, datetime_index, cftime_index): # Fairly extensive testing for standard/proleptic Gregorian calendar From 7a5e0cb35bab0645d036645cf61da5d75d3f36f3 Mon Sep 17 00:00:00 2001 From: Spencer Clark Date: Mon, 25 Feb 2019 19:47:00 -0500 Subject: [PATCH 09/10] Minor edits to docstrings/comments --- xarray/coding/cftime_offsets.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/xarray/coding/cftime_offsets.py b/xarray/coding/cftime_offsets.py index ce9f357989c..9580bbb5d27 100644 --- a/xarray/coding/cftime_offsets.py +++ b/xarray/coding/cftime_offsets.py @@ -267,7 +267,7 @@ def roll_qtrday(other, n, month, day_option, modby=3): See Also -------- - get_day_of_month : Find the day in a month provided an offset. + _get_day_of_month : Find the day in a month provided an offset. """ months_since = other.month % modby - month % modby @@ -396,14 +396,11 @@ def __str__(self): class QuarterBegin(QuarterOffset): - """Default month for QuarterBegin is December - DateOffset increments between Quarter dates. - - month = 1 corresponds to dates like 1/31/2007, 4/30/2007, ... - month = 2 corresponds to dates like 2/28/2007, 5/31/2007, ... - month = 3 corresponds to dates like 3/31/2007, 6/30/2007, ... - """ - # In pandas, _from_name_startingMonth = 1 used when freq='QS' + # When converting a string to an offset, pandas converts + # 'QS' to a QuarterBegin offset starting in the month of + # January. When creating a QuarterBegin offset directly + # from the constructor, however, the default month is March. + # We follow that behavior here. _default_month = 3 _freq = 'QS' _day_option = 'start' @@ -424,10 +421,11 @@ def rollback(self, date): class QuarterEnd(QuarterOffset): - """Default month for QuarterEnd is December - """ - # In pandas, QuarterOffset._from_name suffix == 'DEC' - # See _lite_rule_alias in pandas._libs.tslibs.frequencies + # When converting a string to an offset, pandas converts + # 'Q' to a QuarterEnd offset starting in the month of + # December. When creating a QuarterEnd offset directly + # from the constructor, however, the default month is March. + # We follow that behavior here. _default_month = 3 _freq = 'Q' _day_option = 'end' From 0a1f219a579dfd946a6bcef469efadaa4a27881c Mon Sep 17 00:00:00 2001 From: Spencer Clark Date: Mon, 25 Feb 2019 19:48:42 -0500 Subject: [PATCH 10/10] lint --- xarray/coding/cftime_offsets.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/xarray/coding/cftime_offsets.py b/xarray/coding/cftime_offsets.py index 9580bbb5d27..a74c735224b 100644 --- a/xarray/coding/cftime_offsets.py +++ b/xarray/coding/cftime_offsets.py @@ -396,9 +396,9 @@ def __str__(self): class QuarterBegin(QuarterOffset): - # When converting a string to an offset, pandas converts - # 'QS' to a QuarterBegin offset starting in the month of - # January. When creating a QuarterBegin offset directly + # When converting a string to an offset, pandas converts + # 'QS' to a QuarterBegin offset starting in the month of + # January. When creating a QuarterBegin offset directly # from the constructor, however, the default month is March. # We follow that behavior here. _default_month = 3 @@ -421,9 +421,9 @@ def rollback(self, date): class QuarterEnd(QuarterOffset): - # When converting a string to an offset, pandas converts - # 'Q' to a QuarterEnd offset starting in the month of - # December. When creating a QuarterEnd offset directly + # When converting a string to an offset, pandas converts + # 'Q' to a QuarterEnd offset starting in the month of + # December. When creating a QuarterEnd offset directly # from the constructor, however, the default month is March. # We follow that behavior here. _default_month = 3