diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 130ccded72859..1efb6b414a8b8 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -499,6 +499,7 @@ Datetimelike - Bug in :meth:`DatetimeIndex.is_year_start` and :meth:`DatetimeIndex.is_quarter_start` does not raise on Custom business days frequencies bigger then "1C" (:issue:`58664`) - Bug in :meth:`DatetimeIndex.is_year_start` and :meth:`DatetimeIndex.is_quarter_start` returning ``False`` on double-digit frequencies (:issue:`58523`) - Bug in :meth:`DatetimeIndex.union` when ``unit`` was non-nanosecond (:issue:`59036`) +- Bug in :meth:`Timestamp.replace` where it would not reflect changes into :meth:`Timestamp.unit`. (:issue:`57749`) - Bug in setting scalar values with mismatched resolution into arrays with non-nanosecond ``datetime64``, ``timedelta64`` or :class:`DatetimeTZDtype` incorrectly truncating those scalars (:issue:`56410`) Timedelta diff --git a/pandas/_libs/tslibs/timestamps.pyx b/pandas/_libs/tslibs/timestamps.pyx index 628527bd4ff9b..82ad9dac164fb 100644 --- a/pandas/_libs/tslibs/timestamps.pyx +++ b/pandas/_libs/tslibs/timestamps.pyx @@ -2632,10 +2632,12 @@ default 'raise' datetime ts_input tzinfo_type tzobj _TSObject ts + NPY_DATETIMEUNIT rep_reso # set to naive if needed tzobj = self.tzinfo value = self._value + rep_reso = self._creso # GH 37610. Preserve fold when replacing. if fold is None: @@ -2648,6 +2650,8 @@ default 'raise' pandas_datetime_to_datetimestruct(value, self._creso, &dts) dts.ps = self.nanosecond * 1000 + zero_set = False + # replace def validate(k, v): """ validate integers """ @@ -2659,40 +2663,73 @@ default 'raise' if year is not None: dts.year = validate("year", year) + rep_reso = NPY_DATETIMEUNIT.NPY_FR_Y if month is not None: dts.month = validate("month", month) + rep_reso = NPY_DATETIMEUNIT.NPY_FR_M if day is not None: dts.day = validate("day", day) + rep_reso = NPY_DATETIMEUNIT.NPY_FR_D if hour is not None: dts.hour = validate("hour", hour) + rep_reso = NPY_DATETIMEUNIT.NPY_FR_h if minute is not None: dts.min = validate("minute", minute) + rep_reso = NPY_DATETIMEUNIT.NPY_FR_m if second is not None: dts.sec = validate("second", second) + rep_reso = NPY_DATETIMEUNIT.NPY_FR_s if microsecond is not None: dts.us = validate("microsecond", microsecond) + if microsecond > 999: + rep_reso = NPY_DATETIMEUNIT.NPY_FR_us + else: + rep_reso = NPY_DATETIMEUNIT.NPY_FR_ms + if microsecond == 0: + zero_set = True if nanosecond is not None: dts.ps = validate("nanosecond", nanosecond) * 1000 + rep_reso = NPY_DATETIMEUNIT.NPY_FR_ns + if nanosecond == 0: + zero_set = True if tzinfo is not object: tzobj = tzinfo + # Recalculate the replacement resolution if a unit was replaced with 0 + if zero_set: + if dts.ps != 0: + if dts.ps % 1000 != 0: + rep_reso = NPY_DATETIMEUNIT.NPY_FR_ps + else: + rep_reso = NPY_DATETIMEUNIT.NPY_FR_ns + elif dts.us != 0: + if dts.us % 1000 != 0: + rep_reso = NPY_DATETIMEUNIT.NPY_FR_us + else: + rep_reso = NPY_DATETIMEUNIT.NPY_FR_ms + else: + rep_reso = NPY_DATETIMEUNIT.NPY_FR_s + + if rep_reso < self._creso and not zero_set: + rep_reso = self._creso + # reconstruct & check bounds if tzobj is None: # We can avoid going through pydatetime paths, which is robust # to datetimes outside of pydatetime range. ts = _TSObject() try: - ts.value = npy_datetimestruct_to_datetime(self._creso, &dts) + ts.value = npy_datetimestruct_to_datetime(rep_reso, &dts) except OverflowError as err: fmt = dts_to_iso_string(&dts) raise OutOfBoundsDatetime( f"Out of bounds timestamp: {fmt} with frequency '{self.unit}'" ) from err ts.dts = dts - ts.creso = self._creso + ts.creso = rep_reso ts.fold = fold return create_timestamp_from_ts( - ts.value, dts, tzobj, fold, reso=self._creso + ts.value, dts, tzobj, fold, reso=rep_reso ) elif tzobj is not None and treat_tz_as_pytz(tzobj): @@ -2711,10 +2748,10 @@ default 'raise' ts_input = datetime(**kwargs) ts = convert_datetime_to_tsobject( - ts_input, tzobj, nanos=dts.ps // 1000, reso=self._creso + ts_input, tzobj, nanos=dts.ps // 1000, reso=rep_reso ) return create_timestamp_from_ts( - ts.value, dts, tzobj, fold, reso=self._creso + ts.value, dts, tzobj, fold, reso=rep_reso ) def to_julian_date(self) -> np.float64: diff --git a/pandas/tests/scalar/timestamp/methods/test_replace.py b/pandas/tests/scalar/timestamp/methods/test_replace.py index f15ea0e485cae..039777532ce05 100644 --- a/pandas/tests/scalar/timestamp/methods/test_replace.py +++ b/pandas/tests/scalar/timestamp/methods/test_replace.py @@ -195,3 +195,33 @@ def test_replace_preserves_fold(self, fold): ts_replaced = ts.replace(second=1) assert ts_replaced.fold == fold + + def test_replace_unit(self): + # GH#57749 + ts = Timestamp("2023-07-15 23:08:12") + ts1 = Timestamp("2023-07-15 23:08:12.134567") + ts2 = Timestamp("2023-07-15 23:08:12.134567123") + ts = ts.replace(microsecond=999) + assert ts.unit == "ms" + ts = ts.replace(microsecond=ts1.microsecond) + assert ts.unit == "us" + assert ts == ts1 + ts = ts.replace(nanosecond=ts2.nanosecond) + assert ts.unit == "ns" + assert ts == ts2 + + def test_replace_resets_to_more_precise_s(self): + # GH#57749 + ts = Timestamp(year=2023, month=1, day=1, nanosecond=5) + result = ts.replace(nanosecond=0) + assert result.unit == "s" + + def test_replace_resets_to_more_precise_ms(self): + ts = Timestamp(year=2020, month=1, day=1, microsecond=5, nanosecond=5) + result = ts.replace(nanosecond=0) + assert result.unit == "us" + + def test_replace_resets_to_more_precise_us(self): + ts = Timestamp(year=2020, month=1, day=1, microsecond=2000, nanosecond=5) + result = ts.replace(nanosecond=0) + assert result.unit == "ms"