diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 9fab1d12fc6a5..34c79cec264e2 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -745,6 +745,7 @@ I/O Period ^^^^^^ +- Bug in :meth:`PeriodIndex.to_timestamp` casting to a DatetimeIndex of timestamps at the end of the period, instead of at the beginning of the period. (:issue:`59371`) - Fixed error message when passing invalid period alias to :meth:`PeriodIndex.to_timestamp` (:issue:`58974`) - diff --git a/pandas/core/arrays/period.py b/pandas/core/arrays/period.py index ae92e17332c76..4ebdcf8b787f4 100644 --- a/pandas/core/arrays/period.py +++ b/pandas/core/arrays/period.py @@ -822,7 +822,15 @@ def to_timestamp(self, freq=None, how: str = "start") -> DatetimeArray: new_parr = self.asfreq(freq, how=how) - new_data = libperiod.periodarr_to_dt64arr(new_parr.asi8, base) + is_start = how == "S" + if is_start: + start_time = np.vectorize( + lambda period: (NaT if period is NaT else period.start_time) + ) + new_data = start_time(new_parr) + else: + new_data = libperiod.periodarr_to_dt64arr(new_parr.asi8, base) + dta = DatetimeArray._from_sequence(new_data, dtype=np.dtype("M8[ns]")) if self.freq.name == "B": diff --git a/pandas/tests/indexes/datetimes/methods/test_to_period.py b/pandas/tests/indexes/datetimes/methods/test_to_period.py index cd4a142dd5b30..a619430c06eb9 100644 --- a/pandas/tests/indexes/datetimes/methods/test_to_period.py +++ b/pandas/tests/indexes/datetimes/methods/test_to_period.py @@ -116,13 +116,12 @@ def test_to_period_infer(self): tm.assert_index_equal(pi1, pi2) - @pytest.mark.filterwarnings(r"ignore:PeriodDtype\[B\] is deprecated:FutureWarning") def test_period_dt64_round_trip(self): - dti = date_range("1/1/2000", "1/7/2002", freq="B") + dti = date_range("1/1/2000", "1/7/2002", freq="D") pi = dti.to_period() tm.assert_index_equal(pi.to_timestamp(), dti) - dti = date_range("1/1/2000", "1/7/2002", freq="B") + dti = date_range("1/1/2000", "1/7/2002", freq="D") pi = dti.to_period(freq="h") tm.assert_index_equal(pi.to_timestamp(), dti) diff --git a/pandas/tests/indexes/period/methods/test_to_timestamp.py b/pandas/tests/indexes/period/methods/test_to_timestamp.py index 4fe429ce71ee4..9411e9b773f7d 100644 --- a/pandas/tests/indexes/period/methods/test_to_timestamp.py +++ b/pandas/tests/indexes/period/methods/test_to_timestamp.py @@ -141,6 +141,18 @@ def test_to_timestamp_1703(self): result = index.to_timestamp() assert result[0] == Timestamp("1/1/2012") + def test_cast_to_timestamps_at_beginning_of_period(self): + # GH 59371 + index = period_range("2000", periods=3, freq="M") + result = index.to_timestamp("M") + + expected = DatetimeIndex( + ["2000-01-01", "2000-02-01", "2000-03-01"], + dtype="datetime64[ns]", + freq="MS", + ) + tm.assert_equal(result, expected) + def test_ms_to_timestamp_error_message(): # https://github.com/pandas-dev/pandas/issues/58974#issuecomment-2164265446 diff --git a/pandas/tests/plotting/test_datetimelike.py b/pandas/tests/plotting/test_datetimelike.py index 1275f3d6f7d6d..cb4bd3dedae20 100644 --- a/pandas/tests/plotting/test_datetimelike.py +++ b/pandas/tests/plotting/test_datetimelike.py @@ -862,15 +862,12 @@ def test_mixed_freq_lf_first_hourly(self): for line in ax.get_lines(): assert PeriodIndex(data=line.get_xdata()).freq == "min" - @pytest.mark.filterwarnings(r"ignore:PeriodDtype\[B\] is deprecated:FutureWarning") def test_mixed_freq_irreg_period(self): ts = Series( np.arange(30, dtype=np.float64), index=date_range("2020-01-01", periods=30) ) irreg = ts.iloc[[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 16, 17, 18, 29]] - msg = r"PeriodDtype\[B\] is deprecated" - with tm.assert_produces_warning(FutureWarning, match=msg): - rng = period_range("1/3/2000", periods=30, freq="B") + rng = period_range("1/3/2000", periods=30, freq="D") ps = Series(np.random.default_rng(2).standard_normal(len(rng)), rng) _, ax = mpl.pyplot.subplots() irreg.plot(ax=ax) diff --git a/pandas/tests/resample/test_period_index.py b/pandas/tests/resample/test_period_index.py index e17529dfab00c..c2fcc1a21538c 100644 --- a/pandas/tests/resample/test_period_index.py +++ b/pandas/tests/resample/test_period_index.py @@ -127,20 +127,32 @@ def test_selection(self, freq, kwargs): @pytest.mark.parametrize("month", MONTHS) @pytest.mark.parametrize("meth", ["ffill", "bfill"]) - @pytest.mark.parametrize("conv", ["start", "end"]) @pytest.mark.parametrize( - ("offset", "period"), [("D", "D"), ("B", "B"), ("ME", "M"), ("QE", "Q")] + ("offset", "period", "conv"), + [ + ("D", "D", "start"), + ("D", "D", "end"), + ("B", "B", "start"), + ("B", "B", "end"), + ("MS", "M", "start"), + ("ME", "M", "end"), + ("QS", "Q", "start"), + ("QE", "Q", "end"), + ], ) def test_annual_upsample_cases( self, offset, period, conv, meth, month, simple_period_range_series ): ts = simple_period_range_series("1/1/1990", "12/31/1991", freq=f"Y-{month}") - warn = FutureWarning if period == "B" else None - msg = r"PeriodDtype\[B\] is deprecated" - if warn is None: - msg = "Resampling with a PeriodIndex is deprecated" - warn = FutureWarning - with tm.assert_produces_warning(warn, match=msg): + + msg = ( + r"PeriodDtype\[B\] is deprecated" + if period == "B" + else "Resampling with a PeriodIndex is deprecated" + ) + with tm.assert_produces_warning( + FutureWarning, match=msg, check_stacklevel=False + ): result = getattr(ts.resample(period, convention=conv), meth)() expected = result.to_timestamp(period, how=conv) expected = expected.asfreq(offset, meth).to_period() @@ -183,7 +195,9 @@ def test_basic_upsample(self, freq, simple_period_range_series): result = ts.resample("Y-DEC").mean() msg = "The 'convention' keyword in Series.resample is deprecated" - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning( + FutureWarning, match=msg, check_stacklevel=False + ): resampled = result.resample(freq, convention="end").ffill() expected = result.to_timestamp(freq, how="end") expected = expected.asfreq(freq, "ffill").to_period(freq) @@ -194,7 +208,9 @@ def test_upsample_with_limit(self): ts = Series(np.random.default_rng(2).standard_normal(len(rng)), rng) msg = "The 'convention' keyword in Series.resample is deprecated" - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning( + FutureWarning, match=msg, check_stacklevel=False + ): result = ts.resample("M", convention="end").ffill(limit=2) expected = ts.asfreq("M").reindex(result.index, method="ffill", limit=2) tm.assert_series_equal(result, expected) @@ -217,21 +233,31 @@ def test_annual_upsample2(self): tm.assert_series_equal(result, expected) @pytest.mark.parametrize("month", MONTHS) - @pytest.mark.parametrize("convention", ["start", "end"]) @pytest.mark.parametrize( - ("offset", "period"), [("D", "D"), ("B", "B"), ("ME", "M")] + ("offset", "period", "convention"), + [ + ("D", "D", "start"), + ("D", "D", "end"), + ("B", "B", "start"), + ("B", "B", "end"), + ("MS", "M", "start"), + ("ME", "M", "end"), + ], ) def test_quarterly_upsample( self, month, offset, period, convention, simple_period_range_series ): freq = f"Q-{month}" ts = simple_period_range_series("1/1/1990", "12/31/1995", freq=freq) - warn = FutureWarning if period == "B" else None - msg = r"PeriodDtype\[B\] is deprecated" - if warn is None: - msg = "Resampling with a PeriodIndex is deprecated" - warn = FutureWarning - with tm.assert_produces_warning(warn, match=msg): + + msg = ( + r"PeriodDtype\[B\] is deprecated" + if period == "B" + else "Resampling with a PeriodIndex is deprecated" + ) + with tm.assert_produces_warning( + FutureWarning, match=msg, check_stacklevel=False + ): result = ts.resample(period, convention=convention).ffill() expected = result.to_timestamp(period, how=convention) expected = expected.asfreq(offset, "ffill").to_period() @@ -242,12 +268,14 @@ def test_quarterly_upsample( def test_monthly_upsample(self, target, convention, simple_period_range_series): ts = simple_period_range_series("1/1/1990", "12/31/1995", freq="M") - warn = None if target == "D" else FutureWarning - msg = r"PeriodDtype\[B\] is deprecated" - if warn is None: - msg = "Resampling with a PeriodIndex is deprecated" - warn = FutureWarning - with tm.assert_produces_warning(warn, match=msg): + msg = ( + "Resampling with a PeriodIndex is deprecated" + if target == "D" + else r"PeriodDtype\[B\] is deprecated" + ) + with tm.assert_produces_warning( + FutureWarning, match=msg, check_stacklevel=False + ): result = ts.resample(target, convention=convention).ffill() expected = result.to_timestamp(target, how=convention) expected = expected.asfreq(target, "ffill").to_period() @@ -328,7 +356,7 @@ def test_with_local_timezone(self, tz): series = Series(1, index=index) series = series.tz_convert(local_timezone) msg = "Converting to PeriodArray/Index representation will drop timezone" - with tm.assert_produces_warning(UserWarning, match=msg): + with tm.assert_produces_warning(UserWarning, match=msg, check_stacklevel=False): result = series.resample("D").mean().to_period() # Create the expected series @@ -420,7 +448,7 @@ def test_weekly_upsample(self, day, target, convention, simple_period_range_seri if warn is None: msg = "Resampling with a PeriodIndex is deprecated" warn = FutureWarning - with tm.assert_produces_warning(warn, match=msg): + with tm.assert_produces_warning(warn, match=msg, check_stacklevel=False): result = ts.resample(target, convention=convention).ffill() expected = result.to_timestamp(target, how=convention) expected = expected.asfreq(target, "ffill").to_period() @@ -455,7 +483,9 @@ def test_resample_to_quarterly_start_end(self, simple_period_range_series, how): # conforms, but different month ts = simple_period_range_series("1990", "1992", freq="Y-JUN") msg = "The 'convention' keyword in Series.resample is deprecated" - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning( + FutureWarning, match=msg, check_stacklevel=False + ): result = ts.resample("Q-MAR", convention=how).ffill() expected = ts.asfreq("Q-MAR", how=how) expected = expected.reindex(result.index, method="ffill") @@ -505,7 +535,9 @@ def test_upsample_daily_business_daily(self, simple_period_range_series): ts = simple_period_range_series("1/1/2000", "2/1/2000") msg = "The 'convention' keyword in Series.resample is deprecated" - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning( + FutureWarning, match=msg, check_stacklevel=False + ): result = ts.resample("h", convention="s").asfreq() exp_rng = period_range("1/1/2000", "2/1/2000 23:00", freq="h") expected = ts.asfreq("h", how="s").reindex(exp_rng) @@ -568,7 +600,7 @@ def test_resample_tz_localized2(self): # for good measure msg = "Converting to PeriodArray/Index representation will drop timezone " - with tm.assert_produces_warning(UserWarning, match=msg): + with tm.assert_produces_warning(UserWarning, match=msg, check_stacklevel=False): result = s.resample("D").mean().to_period() ex_index = period_range("2001-09-20", periods=1, freq="D") expected = Series([1.5], index=ex_index) @@ -867,7 +899,9 @@ def test_resample_with_nat(self, periods, values, freq, expected_values): ) expected = DataFrame(expected_values, index=expected_index) msg = "Resampling with a PeriodIndex is deprecated" - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning( + FutureWarning, match=msg, check_stacklevel=False + ): rs = frame.resample(freq) result = rs.mean() tm.assert_frame_equal(result, expected) @@ -906,7 +940,9 @@ def test_resample_with_offset(self, start, end, start_freq, end_freq, offset): pi = period_range(start, end, freq=start_freq) ser = Series(np.arange(len(pi)), index=pi) msg = "Resampling with a PeriodIndex is deprecated" - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning( + FutureWarning, match=msg, check_stacklevel=False + ): rs = ser.resample(end_freq, offset=offset) result = rs.mean() result = result.to_timestamp(end_freq) @@ -919,11 +955,13 @@ def test_resample_with_offset_month(self): pi = period_range("19910905 12:00", "19910909 1:00", freq="h") ser = Series(np.arange(len(pi)), index=pi) msg = "Resampling with a PeriodIndex is deprecated" - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning( + FutureWarning, match=msg, check_stacklevel=False + ): rs = ser.resample("M", offset="3h") result = rs.mean() result = result.to_timestamp("M") - expected = ser.to_timestamp().resample("ME", offset="3h").mean() + expected = ser.to_timestamp().resample("MS", offset="3h").mean() # TODO: is non-tick the relevant characteristic? (GH 33815) expected.index = expected.index._with_freq(None) tm.assert_series_equal(result, expected) @@ -967,7 +1005,9 @@ def test_sum_min_count(self): data[3:6] = np.nan s = Series(data, index).to_period() msg = "Resampling with a PeriodIndex is deprecated" - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning( + FutureWarning, match=msg, check_stacklevel=False + ): rs = s.resample("Q") result = rs.sum(min_count=1) expected = Series( @@ -1055,7 +1095,7 @@ def test_corner_cases_period(simple_period_range_series): len0pts = simple_period_range_series("2007-01", "2010-05", freq="M")[:0] # it works msg = "Resampling with a PeriodIndex is deprecated" - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning(FutureWarning, match=msg, check_stacklevel=False): result = len0pts.resample("Y-DEC").mean() assert len(result) == 0