From 4fadbb06f437e0e5d681af33fdf6bfc820784b42 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 6 May 2024 16:34:02 +0100 Subject: [PATCH 1/4] support timedelta in Q_.init --- pint/facets/plain/quantity.py | 9 ++++++++- pint/testsuite/test_quantity.py | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/pint/facets/plain/quantity.py b/pint/facets/plain/quantity.py index 2727a7da3..a19533c8b 100644 --- a/pint/facets/plain/quantity.py +++ b/pint/facets/plain/quantity.py @@ -203,7 +203,9 @@ def __new__(cls, value, units=None): return copy.copy(value) inst = SharedRegistryObject().__new__(cls) - if units is None: + if units is None and isinstance(value, datetime.timedelta): + units = inst.UnitsContainer({"s": 1}) + elif units is None: units = inst.UnitsContainer() else: if isinstance(units, (UnitsContainer, UnitDefinition)): @@ -223,6 +225,11 @@ def __new__(cls, value, units=None): "units must be of type str, PlainQuantity or " "UnitsContainer; not {}.".format(type(units)) ) + if isinstance(value, datetime.timedelta): + inst._magnitude = value.total_seconds() + inst._units = inst.UnitsContainer({"s": 1}) + return inst.to(units) + if isinstance(value, cls): magnitude = value.to(units)._magnitude else: diff --git a/pint/testsuite/test_quantity.py b/pint/testsuite/test_quantity.py index 8c6f15c49..88c0f35f1 100644 --- a/pint/testsuite/test_quantity.py +++ b/pint/testsuite/test_quantity.py @@ -1884,6 +1884,14 @@ 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 + # TODO: do not subclass from QuantityTestCase class TestCompareNeutral(QuantityTestCase): From 0eeab16baafdf1846e26320ddb6cca93a720ba60 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 6 May 2024 16:40:28 +0100 Subject: [PATCH 2/4] changes --- CHANGES | 3 +++ 1 file changed, 3 insertions(+) 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) From 33cccf16da849b8046fa56b9e4cdec6aef4b41a6 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 6 May 2024 23:04:04 +0100 Subject: [PATCH 3/4] numpy --- pint/compat.py | 25 +++++++++++++++++++++++++ pint/facets/plain/quantity.py | 9 ++++++--- pint/testsuite/test_quantity.py | 14 ++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/pint/compat.py b/pint/compat.py index 277662410..612d6be4c 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 @@ -84,6 +85,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 +138,9 @@ class ndarray: class np_datetime64: pass + class np_timedelta64: + pass + HAS_NUMPY = False NUMPY_VER = "0" NUMERIC_TYPES = (Number, Decimal) @@ -293,6 +298,26 @@ 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 == np_timedelta64: + return True + + +def to_seconds(obj: Any) -> float: + """Convert a timedelta object to seconds.""" + if isinstance(obj, datetime.timedelta): + return obj.total_seconds() + elif isinstance(obj, np_timedelta64) or obj.dtype == np_timedelta64: + return obj.astype(float) + 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 a19533c8b..638be3974 100644 --- a/pint/facets/plain/quantity.py +++ b/pint/facets/plain/quantity.py @@ -30,8 +30,11 @@ deprecated, eq, is_duck_array_type, + is_timedelta, + is_timedelta_array, is_upcast_type, np, + to_seconds, zero_or_nan, ) from ...errors import DimensionalityError, OffsetUnitCalculusError, PintTypeError @@ -203,7 +206,7 @@ def __new__(cls, value, units=None): return copy.copy(value) inst = SharedRegistryObject().__new__(cls) - if units is None and isinstance(value, datetime.timedelta): + if units is None and (is_timedelta(value) or is_timedelta_array(value)): units = inst.UnitsContainer({"s": 1}) elif units is None: units = inst.UnitsContainer() @@ -225,8 +228,8 @@ def __new__(cls, value, units=None): "units must be of type str, PlainQuantity or " "UnitsContainer; not {}.".format(type(units)) ) - if isinstance(value, datetime.timedelta): - inst._magnitude = value.total_seconds() + if is_timedelta(value) or is_timedelta_array(value): + inst._magnitude = to_seconds(value) inst._units = inst.UnitsContainer({"s": 1}) return inst.to(units) diff --git a/pint/testsuite/test_quantity.py b/pint/testsuite/test_quantity.py index 88c0f35f1..6bc9ebb1e 100644 --- a/pint/testsuite/test_quantity.py +++ b/pint/testsuite/test_quantity.py @@ -1892,6 +1892,20 @@ def test_init_quantity(self): assert q_hours == 3 * self.ureg.second assert q_hours.units == self.ureg.hour + @helpers.requires_numpy + def test_init_quantity_np(self): + td = np.timedelta64(3, "s") + 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 + + td = np.array([3], dtype="timedelta64") + assert self.Q_(td) == np.array([3]) * self.ureg.second + q_hours = self.Q_(td, "hours") + assert q_hours == np.array([3]) * self.ureg.second + assert q_hours.units == self.ureg.hour + # TODO: do not subclass from QuantityTestCase class TestCompareNeutral(QuantityTestCase): From 104b2cdc144017a224cfda71a7f75bb74ba3aba2 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 13 May 2024 23:49:19 +0100 Subject: [PATCH 4/4] td64 dtypes that aren't second --- pint/compat.py | 28 +++++++++++---- pint/facets/plain/quantity.py | 21 ++++++------ pint/testsuite/test_quantity.py | 60 +++++++++++++++++++++++++++------ 3 files changed, 82 insertions(+), 27 deletions(-) diff --git a/pint/compat.py b/pint/compat.py index 612d6be4c..783fa236c 100644 --- a/pint/compat.py +++ b/pint/compat.py @@ -20,6 +20,7 @@ from typing import ( Any, NoReturn, + Tuple, TypeAlias, # noqa ) @@ -305,16 +306,31 @@ def is_timedelta(obj: Any) -> bool: def is_timedelta_array(obj: Any) -> bool: """Check if the object is a datetime array.""" - if isinstance(obj, ndarray) and obj.dtype == np_timedelta64: + if isinstance(obj, ndarray) and obj.dtype.type == np_timedelta64: return True -def to_seconds(obj: Any) -> float: - """Convert a timedelta object to seconds.""" +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() - elif isinstance(obj, np_timedelta64) or obj.dtype == np_timedelta64: - return obj.astype(float) + 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.") diff --git a/pint/facets/plain/quantity.py b/pint/facets/plain/quantity.py index 638be3974..eabc59662 100644 --- a/pint/facets/plain/quantity.py +++ b/pint/facets/plain/quantity.py @@ -27,6 +27,7 @@ from ...compat import ( HAS_NUMPY, _to_magnitude, + convert_timedelta, deprecated, eq, is_duck_array_type, @@ -34,7 +35,6 @@ is_timedelta_array, is_upcast_type, np, - to_seconds, zero_or_nan, ) from ...errors import DimensionalityError, OffsetUnitCalculusError, PintTypeError @@ -204,11 +204,17 @@ def __new__(cls, value, units=None): if units is None and isinstance(value, cls): return copy.copy(value) - inst = SharedRegistryObject().__new__(cls) - if units is None and (is_timedelta(value) or is_timedelta_array(value)): - units = inst.UnitsContainer({"s": 1}) - elif units is None: + + 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: if isinstance(units, (UnitsContainer, UnitDefinition)): @@ -228,11 +234,6 @@ def __new__(cls, value, units=None): "units must be of type str, PlainQuantity or " "UnitsContainer; not {}.".format(type(units)) ) - if is_timedelta(value) or is_timedelta_array(value): - inst._magnitude = to_seconds(value) - inst._units = inst.UnitsContainer({"s": 1}) - return inst.to(units) - if isinstance(value, cls): magnitude = value.to(units)._magnitude else: diff --git a/pint/testsuite/test_quantity.py b/pint/testsuite/test_quantity.py index 6bc9ebb1e..3f9af463b 100644 --- a/pint/testsuite/test_quantity.py +++ b/pint/testsuite/test_quantity.py @@ -1892,19 +1892,57 @@ def test_init_quantity(self): 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): - td = np.timedelta64(3, "s") - 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 + 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) + ) - td = np.array([3], dtype="timedelta64") - assert self.Q_(td) == np.array([3]) * self.ureg.second - q_hours = self.Q_(td, "hours") - assert q_hours == np.array([3]) * self.ureg.second - assert q_hours.units == self.ureg.hour + # 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