Skip to content

Commit c9ba2be

Browse files
allow negative freq strings (#8651)
* allow negative freq strings * update docstring * fix date_range_like * whats-new entry * Apply suggestions from code review Co-authored-by: Spencer Clark <[email protected]> * only test standard calendar * merge tests --------- Co-authored-by: Spencer Clark <[email protected]>
1 parent 4de10d4 commit c9ba2be

File tree

3 files changed

+103
-24
lines changed

3 files changed

+103
-24
lines changed

doc/whats-new.rst

+3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ v2024.02.0 (unreleased)
2323
New Features
2424
~~~~~~~~~~~~
2525

26+
- Allow negative frequency strings (e.g. ``"-1YE"``). These strings are for example used
27+
in :py:func:`date_range`, and :py:func:`cftime_range` (:pull:`8651`).
28+
By `Mathias Hauser <https://github.com/mathause>`_.
2629
- Add :py:meth:`NamedArray.expand_dims`, :py:meth:`NamedArray.permute_dims` and :py:meth:`NamedArray.broadcast_to`
2730
(:pull:`8380`) By `Anderson Banihirwe <https://github.com/andersy005>`_.
2831

xarray/coding/cftime_offsets.py

+12-9
Original file line numberDiff line numberDiff line change
@@ -700,7 +700,7 @@ def _generate_anchored_offsets(base_freq, offset):
700700

701701

702702
_FREQUENCY_CONDITION = "|".join(_FREQUENCIES.keys())
703-
_PATTERN = rf"^((?P<multiple>\d+)|())(?P<freq>({_FREQUENCY_CONDITION}))$"
703+
_PATTERN = rf"^((?P<multiple>[+-]?\d+)|())(?P<freq>({_FREQUENCY_CONDITION}))$"
704704

705705

706706
# pandas defines these offsets as "Tick" objects, which for instance have
@@ -825,7 +825,8 @@ def _generate_range(start, end, periods, offset):
825825
"""Generate a regular range of cftime.datetime objects with a
826826
given time offset.
827827
828-
Adapted from pandas.tseries.offsets.generate_range.
828+
Adapted from pandas.tseries.offsets.generate_range (now at
829+
pandas.core.arrays.datetimes._generate_range).
829830
830831
Parameters
831832
----------
@@ -845,10 +846,7 @@ def _generate_range(start, end, periods, offset):
845846
if start:
846847
start = offset.rollforward(start)
847848

848-
if end:
849-
end = offset.rollback(end)
850-
851-
if periods is None and end < start:
849+
if periods is None and end < start and offset.n >= 0:
852850
end = None
853851
periods = 0
854852

@@ -933,7 +931,7 @@ def cftime_range(
933931
periods : int, optional
934932
Number of periods to generate.
935933
freq : str or None, default: "D"
936-
Frequency strings can have multiples, e.g. "5h".
934+
Frequency strings can have multiples, e.g. "5h" and negative values, e.g. "-1D".
937935
normalize : bool, default: False
938936
Normalize start/end dates to midnight before generating date range.
939937
name : str, default: None
@@ -1176,7 +1174,7 @@ def date_range(
11761174
periods : int, optional
11771175
Number of periods to generate.
11781176
freq : str or None, default: "D"
1179-
Frequency strings can have multiples, e.g. "5h".
1177+
Frequency strings can have multiples, e.g. "5h" and negative values, e.g. "-1D".
11801178
tz : str or tzinfo, optional
11811179
Time zone name for returning localized DatetimeIndex, for example
11821180
'Asia/Hong_Kong'. By default, the resulting DatetimeIndex is
@@ -1322,6 +1320,11 @@ def date_range_like(source, calendar, use_cftime=None):
13221320

13231321
source_start = source.values.min()
13241322
source_end = source.values.max()
1323+
1324+
freq_as_offset = to_offset(freq)
1325+
if freq_as_offset.n < 0:
1326+
source_start, source_end = source_end, source_start
1327+
13251328
if is_np_datetime_like(source.dtype):
13261329
# We want to use datetime fields (datetime64 object don't have them)
13271330
source_calendar = "standard"
@@ -1344,7 +1347,7 @@ def date_range_like(source, calendar, use_cftime=None):
13441347

13451348
# For the cases where the source ends on the end of the month, we expect the same in the new calendar.
13461349
if source_end.day == source_end.daysinmonth and isinstance(
1347-
to_offset(freq), (YearEnd, QuarterEnd, MonthEnd, Day)
1350+
freq_as_offset, (YearEnd, QuarterEnd, MonthEnd, Day)
13481351
):
13491352
end = end.replace(day=end.daysinmonth)
13501353

xarray/tests/test_cftime_offsets.py

+88-15
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,20 @@ def test_to_offset_offset_input(offset):
225225
("2U", Microsecond(n=2)),
226226
("us", Microsecond(n=1)),
227227
("2us", Microsecond(n=2)),
228+
# negative
229+
("-2M", MonthEnd(n=-2)),
230+
("-2ME", MonthEnd(n=-2)),
231+
("-2MS", MonthBegin(n=-2)),
232+
("-2D", Day(n=-2)),
233+
("-2H", Hour(n=-2)),
234+
("-2h", Hour(n=-2)),
235+
("-2T", Minute(n=-2)),
236+
("-2min", Minute(n=-2)),
237+
("-2S", Second(n=-2)),
238+
("-2L", Millisecond(n=-2)),
239+
("-2ms", Millisecond(n=-2)),
240+
("-2U", Microsecond(n=-2)),
241+
("-2us", Microsecond(n=-2)),
228242
],
229243
ids=_id_func,
230244
)
@@ -239,7 +253,7 @@ def test_to_offset_sub_annual(freq, expected):
239253
@pytest.mark.parametrize(
240254
("month_int", "month_label"), list(_MONTH_ABBREVIATIONS.items()) + [(0, "")]
241255
)
242-
@pytest.mark.parametrize("multiple", [None, 2])
256+
@pytest.mark.parametrize("multiple", [None, 2, -1])
243257
@pytest.mark.parametrize("offset_str", ["AS", "A", "YS", "Y"])
244258
@pytest.mark.filterwarnings("ignore::FutureWarning") # Deprecation of "A" etc.
245259
def test_to_offset_annual(month_label, month_int, multiple, offset_str):
@@ -268,7 +282,7 @@ def test_to_offset_annual(month_label, month_int, multiple, offset_str):
268282
@pytest.mark.parametrize(
269283
("month_int", "month_label"), list(_MONTH_ABBREVIATIONS.items()) + [(0, "")]
270284
)
271-
@pytest.mark.parametrize("multiple", [None, 2])
285+
@pytest.mark.parametrize("multiple", [None, 2, -1])
272286
@pytest.mark.parametrize("offset_str", ["QS", "Q", "QE"])
273287
@pytest.mark.filterwarnings("ignore::FutureWarning") # Deprecation of "Q" etc.
274288
def test_to_offset_quarter(month_label, month_int, multiple, offset_str):
@@ -403,6 +417,7 @@ def test_eq(a, b):
403417

404418
_MUL_TESTS = [
405419
(BaseCFTimeOffset(), 3, BaseCFTimeOffset(n=3)),
420+
(BaseCFTimeOffset(), -3, BaseCFTimeOffset(n=-3)),
406421
(YearEnd(), 3, YearEnd(n=3)),
407422
(YearBegin(), 3, YearBegin(n=3)),
408423
(QuarterEnd(), 3, QuarterEnd(n=3)),
@@ -418,6 +433,7 @@ def test_eq(a, b):
418433
(Microsecond(), 3, Microsecond(n=3)),
419434
(Day(), 0.5, Hour(n=12)),
420435
(Hour(), 0.5, Minute(n=30)),
436+
(Hour(), -0.5, Minute(n=-30)),
421437
(Minute(), 0.5, Second(n=30)),
422438
(Second(), 0.5, Millisecond(n=500)),
423439
(Millisecond(), 0.5, Microsecond(n=500)),
@@ -1162,6 +1178,15 @@ def test_rollback(calendar, offset, initial_date_args, partial_expected_date_arg
11621178
False,
11631179
[(10, 1, 1), (8, 1, 1), (6, 1, 1), (4, 1, 1)],
11641180
),
1181+
(
1182+
"0010",
1183+
None,
1184+
4,
1185+
"-2YS",
1186+
"both",
1187+
False,
1188+
[(10, 1, 1), (8, 1, 1), (6, 1, 1), (4, 1, 1)],
1189+
),
11651190
(
11661191
"0001-01-01",
11671192
"0001-01-04",
@@ -1180,6 +1205,24 @@ def test_rollback(calendar, offset, initial_date_args, partial_expected_date_arg
11801205
False,
11811206
[(1, 6, 1), (2, 3, 1), (2, 12, 1), (3, 9, 1)],
11821207
),
1208+
(
1209+
"0001-06-01",
1210+
None,
1211+
4,
1212+
"-1MS",
1213+
"both",
1214+
False,
1215+
[(1, 6, 1), (1, 5, 1), (1, 4, 1), (1, 3, 1)],
1216+
),
1217+
(
1218+
"0001-01-30",
1219+
None,
1220+
4,
1221+
"-1D",
1222+
"both",
1223+
False,
1224+
[(1, 1, 30), (1, 1, 29), (1, 1, 28), (1, 1, 27)],
1225+
),
11831226
]
11841227

11851228

@@ -1263,32 +1306,52 @@ def test_invalid_cftime_arg() -> None:
12631306

12641307

12651308
_CALENDAR_SPECIFIC_MONTH_END_TESTS = [
1266-
("2ME", "noleap", [(2, 28), (4, 30), (6, 30), (8, 31), (10, 31), (12, 31)]),
1267-
("2ME", "all_leap", [(2, 29), (4, 30), (6, 30), (8, 31), (10, 31), (12, 31)]),
1268-
("2ME", "360_day", [(2, 30), (4, 30), (6, 30), (8, 30), (10, 30), (12, 30)]),
1269-
("2ME", "standard", [(2, 29), (4, 30), (6, 30), (8, 31), (10, 31), (12, 31)]),
1270-
("2ME", "gregorian", [(2, 29), (4, 30), (6, 30), (8, 31), (10, 31), (12, 31)]),
1271-
("2ME", "julian", [(2, 29), (4, 30), (6, 30), (8, 31), (10, 31), (12, 31)]),
1309+
("noleap", [(2, 28), (4, 30), (6, 30), (8, 31), (10, 31), (12, 31)]),
1310+
("all_leap", [(2, 29), (4, 30), (6, 30), (8, 31), (10, 31), (12, 31)]),
1311+
("360_day", [(2, 30), (4, 30), (6, 30), (8, 30), (10, 30), (12, 30)]),
1312+
("standard", [(2, 29), (4, 30), (6, 30), (8, 31), (10, 31), (12, 31)]),
1313+
("gregorian", [(2, 29), (4, 30), (6, 30), (8, 31), (10, 31), (12, 31)]),
1314+
("julian", [(2, 29), (4, 30), (6, 30), (8, 31), (10, 31), (12, 31)]),
12721315
]
12731316

12741317

12751318
@pytest.mark.parametrize(
1276-
("freq", "calendar", "expected_month_day"),
1319+
("calendar", "expected_month_day"),
12771320
_CALENDAR_SPECIFIC_MONTH_END_TESTS,
12781321
ids=_id_func,
12791322
)
12801323
def test_calendar_specific_month_end(
1281-
freq: str, calendar: str, expected_month_day: list[tuple[int, int]]
1324+
calendar: str, expected_month_day: list[tuple[int, int]]
12821325
) -> None:
12831326
year = 2000 # Use a leap-year to highlight calendar differences
12841327
result = cftime_range(
1285-
start="2000-02", end="2001", freq=freq, calendar=calendar
1328+
start="2000-02", end="2001", freq="2ME", calendar=calendar
12861329
).values
12871330
date_type = get_date_type(calendar)
12881331
expected = [date_type(year, *args) for args in expected_month_day]
12891332
np.testing.assert_equal(result, expected)
12901333

12911334

1335+
@pytest.mark.parametrize(
1336+
("calendar", "expected_month_day"),
1337+
_CALENDAR_SPECIFIC_MONTH_END_TESTS,
1338+
ids=_id_func,
1339+
)
1340+
def test_calendar_specific_month_end_negative_freq(
1341+
calendar: str, expected_month_day: list[tuple[int, int]]
1342+
) -> None:
1343+
year = 2000 # Use a leap-year to highlight calendar differences
1344+
result = cftime_range(
1345+
start="2000-12",
1346+
end="2000",
1347+
freq="-2ME",
1348+
calendar=calendar,
1349+
).values
1350+
date_type = get_date_type(calendar)
1351+
expected = [date_type(year, *args) for args in expected_month_day[::-1]]
1352+
np.testing.assert_equal(result, expected)
1353+
1354+
12921355
@pytest.mark.parametrize(
12931356
("calendar", "start", "end", "expected_number_of_days"),
12941357
[
@@ -1376,10 +1439,6 @@ def test_date_range_errors() -> None:
13761439
)
13771440

13781441

1379-
@requires_cftime
1380-
@pytest.mark.xfail(
1381-
reason="https://github.com/pydata/xarray/pull/8636#issuecomment-1902775153"
1382-
)
13831442
@pytest.mark.parametrize(
13841443
"start,freq,cal_src,cal_tgt,use_cftime,exp0,exp_pd",
13851444
[
@@ -1388,9 +1447,11 @@ def test_date_range_errors() -> None:
13881447
("2020-02-01", "QE-DEC", "noleap", "gregorian", True, "2020-03-31", True),
13891448
("2020-02-01", "YS-FEB", "noleap", "gregorian", True, "2020-02-01", True),
13901449
("2020-02-01", "YE-FEB", "noleap", "gregorian", True, "2020-02-29", True),
1450+
("2020-02-01", "-1YE-FEB", "noleap", "gregorian", True, "2020-02-29", True),
13911451
("2020-02-28", "3h", "all_leap", "gregorian", False, "2020-02-28", True),
13921452
("2020-03-30", "ME", "360_day", "gregorian", False, "2020-03-31", True),
13931453
("2020-03-31", "ME", "gregorian", "360_day", None, "2020-03-30", False),
1454+
("2020-03-31", "-1ME", "gregorian", "360_day", None, "2020-03-30", False),
13941455
],
13951456
)
13961457
def test_date_range_like(start, freq, cal_src, cal_tgt, use_cftime, exp0, exp_pd):
@@ -1541,3 +1602,15 @@ def test_to_offset_deprecation_warning(freq):
15411602
# Test for deprecations outlined in GitHub issue #8394
15421603
with pytest.warns(FutureWarning, match="is deprecated"):
15431604
to_offset(freq)
1605+
1606+
1607+
@pytest.mark.filterwarnings("ignore:Converting a CFTimeIndex with:")
1608+
@pytest.mark.parametrize("start", ("2000", "2001"))
1609+
@pytest.mark.parametrize("end", ("2000", "2001"))
1610+
@pytest.mark.parametrize("freq", ("MS", "-1MS", "YS", "-1YS", "M", "-1M", "Y", "-1Y"))
1611+
def test_cftime_range_same_as_pandas(start, end, freq):
1612+
result = date_range(start, end, freq=freq, calendar="standard", use_cftime=True)
1613+
result = result.to_datetimeindex()
1614+
expected = date_range(start, end, freq=freq, use_cftime=False)
1615+
1616+
np.testing.assert_array_equal(result, expected)

0 commit comments

Comments
 (0)