diff --git a/CHANGES b/CHANGES index 048765ec0..362dcb3b8 100644 --- a/CHANGES +++ b/CHANGES @@ -7,6 +7,9 @@ Pint Changelog - Add `dim_sort` function to _formatter_helpers. - Add `dim_order` and `default_sort_func` properties to FullFormatter. (PR #1926, fixes Issue #1841) +- `Quantity` now converts `datetime.timedelta` objects to seconds or specified units when + initializing a `Quantity` with a `datetime.timedelta` value. + (PR #1978) 0.23 (2023-12-08) diff --git a/pint/compat.py b/pint/compat.py index 277662410..783fa236c 100644 --- a/pint/compat.py +++ b/pint/compat.py @@ -10,6 +10,7 @@ from __future__ import annotations +import datetime import math import sys from collections.abc import Callable, Iterable, Mapping @@ -19,6 +20,7 @@ from typing import ( Any, NoReturn, + Tuple, TypeAlias, # noqa ) @@ -84,6 +86,7 @@ class BehaviorChangeWarning(UserWarning): import numpy as np from numpy import datetime64 as np_datetime64 from numpy import ndarray + from numpy import timedelta64 as np_timedelta64 HAS_NUMPY = True NUMPY_VER = np.__version__ @@ -136,6 +139,9 @@ class ndarray: class np_datetime64: pass + class np_timedelta64: + pass + HAS_NUMPY = False NUMPY_VER = "0" NUMERIC_TYPES = (Number, Decimal) @@ -293,6 +299,41 @@ def is_duck_array_type(cls: type) -> bool: ) +def is_timedelta(obj: Any) -> bool: + """Check if the object is a datetime object.""" + return isinstance(obj, datetime.timedelta) or isinstance(obj, np_timedelta64) + + +def is_timedelta_array(obj: Any) -> bool: + """Check if the object is a datetime array.""" + if isinstance(obj, ndarray) and obj.dtype.type == np_timedelta64: + return True + + +def convert_timedelta(obj: Any) -> Tuple[float, str]: + """Convert a timedelta object to magnitude and unit string.""" + _dtype_to_unit = { + "timedelta64[Y]": "year", + "timedelta64[M]": "month", + "timedelta64[W]": "week", + "timedelta64[D]": "day", + "timedelta64[h]": "hour", + "timedelta64[m]": "minute", + "timedelta64[s]": "s", + "timedelta64[ms]": "ms", + "timedelta64[us]": "us", + "timedelta64[ns]": "ns", + "timedelta64[ps]": "ps", + "timedelta64[fs]": "fs", + "timedelta64[as]": "as", + } + if isinstance(obj, datetime.timedelta): + return obj.total_seconds(), "s" + elif isinstance(obj, np_timedelta64) or obj.dtype.type == np_timedelta64: + return obj.astype(float), _dtype_to_unit[str(obj.dtype)] + raise TypeError(f"Cannot convert {obj!r} to seconds.") + + def is_duck_array(obj: type) -> bool: """Check if an object represents a (non-Quantity) duck array type.""" return is_duck_array_type(type(obj)) diff --git a/pint/facets/plain/quantity.py b/pint/facets/plain/quantity.py index 2727a7da3..eabc59662 100644 --- a/pint/facets/plain/quantity.py +++ b/pint/facets/plain/quantity.py @@ -27,9 +27,12 @@ from ...compat import ( HAS_NUMPY, _to_magnitude, + convert_timedelta, deprecated, eq, is_duck_array_type, + is_timedelta, + is_timedelta_array, is_upcast_type, np, zero_or_nan, @@ -201,8 +204,16 @@ def __new__(cls, value, units=None): if units is None and isinstance(value, cls): return copy.copy(value) - inst = SharedRegistryObject().__new__(cls) + + if is_timedelta(value) or is_timedelta_array(value): + m, u = convert_timedelta(value) + inst._magnitude = m + inst._units = inst.UnitsContainer({u: 1}) + if units: + inst.ito(units) + return inst + if units is None: units = inst.UnitsContainer() else: diff --git a/pint/testsuite/test_quantity.py b/pint/testsuite/test_quantity.py index 8c6f15c49..3f9af463b 100644 --- a/pint/testsuite/test_quantity.py +++ b/pint/testsuite/test_quantity.py @@ -1884,6 +1884,66 @@ def test_iadd_isub(self): with pytest.raises(DimensionalityError): after -= d + def test_init_quantity(self): + # 608 + td = datetime.timedelta(seconds=3) + assert self.Q_(td) == 3 * self.ureg.second + q_hours = self.Q_(td, "hours") + assert q_hours == 3 * self.ureg.second + assert q_hours.units == self.ureg.hour + + @pytest.mark.parametrize( + ["timedelta_unit", "pint_unit"], + ( + pytest.param("s", "second", id="second"), + pytest.param("ms", "millisecond", id="millisecond"), + pytest.param("us", "microsecond", id="microsecond"), + pytest.param("ns", "nanosecond", id="nanosecond"), + pytest.param("m", "minute", id="minute"), + pytest.param("h", "hour", id="hour"), + pytest.param("D", "day", id="day"), + pytest.param("W", "week", id="week"), + pytest.param("M", "month", id="month"), + pytest.param("Y", "year", id="year"), + ), + ) + @helpers.requires_numpy + def test_init_quantity_np(self, timedelta_unit, pint_unit): + # test init with the timedelta unit + td = np.timedelta64(3, timedelta_unit) + result = self.Q_(td) + expected = self.Q_(3, pint_unit) + helpers.assert_quantity_almost_equal(result, expected) + # check units are same. Use Q_ since Unit(s) != Unit(second) + helpers.assert_quantity_almost_equal( + self.Q_(1, result.units), self.Q_(1, expected.units) + ) + + # test init with unit specified + result = self.Q_(td, "hours") + expected = self.Q_(3, pint_unit).to("hours") + helpers.assert_quantity_almost_equal(result, expected) + helpers.assert_quantity_almost_equal( + self.Q_(1, result.units), self.Q_(1, expected.units) + ) + + # test array + td = np.array([3], dtype="timedelta64[{}]".format(timedelta_unit)) + result = self.Q_(td) + expected = self.Q_([3], pint_unit) + helpers.assert_quantity_almost_equal(result, expected) + helpers.assert_quantity_almost_equal( + self.Q_(1, result.units), self.Q_(1, expected.units) + ) + + # test array with unit specified + result = self.Q_(td, "hours") + expected = self.Q_([3], pint_unit).to("hours") + helpers.assert_quantity_almost_equal(result, expected) + helpers.assert_quantity_almost_equal( + self.Q_(1, result.units), self.Q_(1, expected.units) + ) + # TODO: do not subclass from QuantityTestCase class TestCompareNeutral(QuantityTestCase):