Skip to content

Add support for NumPy 2.0 #232

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ jobs:
- python-version: "3.12"
numpy-version: "1.26"
os: ubuntu-latest
- python-version: "3.12"
numpy-version: "2.0"
os: ubuntu-latest
steps:
- uses: actions/checkout@v2

Expand Down Expand Up @@ -128,4 +131,4 @@ jobs:

- name: Check type information
run: |
mypy quantities
mypy quantities
2 changes: 2 additions & 0 deletions quantities/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@

"""

class QuantitiesDeprecationWarning(DeprecationWarning):
pass

from ._version import __version__

Expand Down
9 changes: 6 additions & 3 deletions quantities/dimensionality.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from .registry import unit_registry
from .decorators import memoize

_np_version = tuple(map(int, np.__version__.split('.')))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now breaking the tests in MNE:

     _np_version = tuple(map(int, np.__version__.split('.')))
E   ValueError: invalid literal for int() with base 10: 'dev0+git20240824'

x-ref: mne-tools/mne-python#12815

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you all testing against a development version of NumPy? I don't think @bjodah or I thought about that in these PRs for parsing the numpy version. I guess a version parse instead would be better if you want to defend against dev version testing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MNE is testing against stable and the development version of numpy, all my projects are also tested against both, and I'm probably not the only one ;)
Quick fix in #238 but maybe you want something a bit more elaborate 😃


def assert_isinstance(obj, types):
try:
assert isinstance(obj, types)
Expand Down Expand Up @@ -329,10 +331,11 @@ def _d_copy(q1, out=None):

def _d_clip(a1, a2, a3, q):
return q.dimensionality
try:

if _np_version < (2, 0, 0):
p_dict[np.core.umath.clip] = _d_clip
except AttributeError:
pass # For compatibility with Numpy < 1.17 when clip wasn't a ufunc yet
else:
p_dict[np.clip] = _d_clip

def _d_sqrt(q1, out=None):
return q1._dimensionality**0.5
Expand Down
76 changes: 39 additions & 37 deletions quantities/quantity.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@

import copy
from functools import wraps
import warnings

import numpy as np

from . import markup
from . import markup, QuantitiesDeprecationWarning
from .dimensionality import Dimensionality, p_dict
from .registry import unit_registry
from .decorators import with_doc
Expand Down Expand Up @@ -114,15 +115,19 @@ class Quantity(np.ndarray):
# TODO: what is an appropriate value?
__array_priority__ = 21

def __new__(cls, data, units='', dtype=None, copy=True):
def __new__(cls, data, units='', dtype=None, copy=None):
if copy is not None:
warnings.warn(("The 'copy' argument in Quantity is deprecated and will be removed in the future. "
"The argument has no effect since quantities-0.16.0 (to aid numpy-2.0 support)."),
QuantitiesDeprecationWarning, stacklevel=2)
if isinstance(data, Quantity):
if units:
data = data.rescale(units)
if isinstance(data, unit_registry['UnitQuantity']):
return 1*data
return np.array(data, dtype=dtype, copy=copy, subok=True).view(cls)
return np.asanyarray(data, dtype=dtype).view(cls)

ret = np.array(data, dtype=dtype, copy=copy).view(cls)
ret = np.asarray(data, dtype=dtype).view(cls)
Copy link
Contributor Author

@bjodah bjodah Jun 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After these changes we are no longer using the copy keyword argument passed into __new__. I contemplated dropping it, but I fear that the change would be too disruptive for backwards compatibility. Perhaps changing its default to None and conditionally issuing a DeprecationWarning when it is set to True/False is a viable option? And the deprecation warning would inform the user that the copy-kwarg will be dropped in future release of quantities.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good idea.

ret._dimensionality.update(validate_dimensionality(units))
return ret

Expand Down Expand Up @@ -210,15 +215,17 @@ def rescale(self, units=None, dtype=None):
dtype = self.dtype
if self.dimensionality == to_dims:
return self.astype(dtype)
to_u = Quantity(1.0, to_dims)
from_u = Quantity(1.0, self.dimensionality)
to_u = Quantity(1.0, to_dims, dtype=dtype)
from_u = Quantity(1.0, self.dimensionality, dtype=dtype)
try:
cf = get_conversion_factor(from_u, to_u)
except AssertionError:
raise ValueError(
'Unable to convert between units of "%s" and "%s"'
%(from_u._dimensionality, to_u._dimensionality)
)
if np.dtype(dtype).kind in 'fc':
cf = np.array(cf, dtype=dtype)
new_magnitude = cf*self.magnitude
dtype = np.result_type(dtype, new_magnitude)
return Quantity(new_magnitude, to_u, dtype=dtype)
Expand Down Expand Up @@ -272,7 +279,7 @@ def __array_prepare__(self, obj, context=None):
uf, objs, huh = context
if uf.__name__.startswith('is'):
return obj
#print self, obj, res, uf, objs

try:
res._dimensionality = p_dict[uf](*objs)
except KeyError:
Expand All @@ -283,11 +290,12 @@ def __array_prepare__(self, obj, context=None):
)
return res

def __array_wrap__(self, obj, context=None):
def __array_wrap__(self, obj, context=None, return_scalar=False):
if not isinstance(obj, Quantity):
# backwards compatibility with numpy-1.3
obj = self.__array_prepare__(obj, context)
return obj
return self.__array_prepare__(obj, context)
else:
return super().__array_wrap__(obj, context, return_scalar)
Comment on lines +296 to +298
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused by this. The previous array wrap would only do something if the object was not a Quantity if the object was already a quantity it would just return the object without any changes. Here you force the array_wrap even in the case of the object being a Quantity. It seems like for the case of some failing tests the objects are losing their dimensionality and this seems like it could be a place where a Quantity should be returned, no?

I'm not super familiar with this deep level of array wrapping so just thought maybe a discussion of this could help point us to the actual test failure issue.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bjodah just wanted to bump my message. Does this seem like it could be the problem with the tests failing?


@with_doc(np.ndarray.__add__)
@scale_other_units
Expand Down Expand Up @@ -476,7 +484,7 @@ def sum(self, axis=None, dtype=None, out=None):
ret = self.magnitude.sum(axis, dtype, None if out is None else out.magnitude)
dim = self.dimensionality
if out is None:
return Quantity(ret, dim, copy=False)
return Quantity(ret, dim)
if not isinstance(out, Quantity):
raise TypeError("out parameter must be a Quantity")
out._dimensionality = dim
Expand All @@ -487,8 +495,7 @@ def nansum(self, axis=None, dtype=None, out=None):
import numpy as np
return Quantity(
np.nansum(self.magnitude, axis, dtype, out),
self.dimensionality,
copy=False
self.dimensionality
)

@with_doc(np.ndarray.fill)
Expand Down Expand Up @@ -523,7 +530,7 @@ def argsort(self, axis=-1, kind='quick', order=None):
@with_doc(np.ndarray.searchsorted)
def searchsorted(self,values, side='left'):
if not isinstance (values, Quantity):
values = Quantity(values, copy=False)
values = Quantity(values)

if values._dimensionality != self._dimensionality:
raise ValueError("values does not have the same units as self")
Expand All @@ -539,7 +546,7 @@ def max(self, axis=None, out=None):
ret = self.magnitude.max(axis, None if out is None else out.magnitude)
dim = self.dimensionality
if out is None:
return Quantity(ret, dim, copy=False)
return Quantity(ret, dim)
if not isinstance(out, Quantity):
raise TypeError("out parameter must be a Quantity")
out._dimensionality = dim
Expand All @@ -553,16 +560,15 @@ def argmax(self, axis=None, out=None):
def nanmax(self, axis=None, out=None):
return Quantity(
np.nanmax(self.magnitude),
self.dimensionality,
copy=False
self.dimensionality
)

@with_doc(np.ndarray.min)
def min(self, axis=None, out=None):
ret = self.magnitude.min(axis, None if out is None else out.magnitude)
dim = self.dimensionality
if out is None:
return Quantity(ret, dim, copy=False)
return Quantity(ret, dim)
if not isinstance(out, Quantity):
raise TypeError("out parameter must be a Quantity")
out._dimensionality = dim
Expand All @@ -572,8 +578,7 @@ def min(self, axis=None, out=None):
def nanmin(self, axis=None, out=None):
return Quantity(
np.nanmin(self.magnitude),
self.dimensionality,
copy=False
self.dimensionality
)

@with_doc(np.ndarray.argmin)
Expand All @@ -590,10 +595,10 @@ def nanargmax(self,axis=None, out=None):

@with_doc(np.ndarray.ptp)
def ptp(self, axis=None, out=None):
ret = self.magnitude.ptp(axis, None if out is None else out.magnitude)
ret = np.ptp(self.magnitude, axis, None if out is None else out.magnitude)
dim = self.dimensionality
if out is None:
return Quantity(ret, dim, copy=False)
return Quantity(ret, dim)
if not isinstance(out, Quantity):
raise TypeError("out parameter must be a Quantity")
out._dimensionality = dim
Expand All @@ -620,7 +625,7 @@ def clip(self, min=None, max=None, out=None):
)
dim = self.dimensionality
if out is None:
return Quantity(clipped, dim, copy=False)
return Quantity(clipped, dim)
if not isinstance(out, Quantity):
raise TypeError("out parameter must be a Quantity")
out._dimensionality = dim
Expand All @@ -631,7 +636,7 @@ def round(self, decimals=0, out=None):
ret = self.magnitude.round(decimals, None if out is None else out.magnitude)
dim = self.dimensionality
if out is None:
return Quantity(ret, dim, copy=False)
return Quantity(ret, dim)
if not isinstance(out, Quantity):
raise TypeError("out parameter must be a Quantity")
out._dimensionality = dim
Expand All @@ -642,7 +647,7 @@ def trace(self, offset=0, axis1=0, axis2=1, dtype=None, out=None):
ret = self.magnitude.trace(offset, axis1, axis2, dtype, None if out is None else out.magnitude)
dim = self.dimensionality
if out is None:
return Quantity(ret, dim, copy=False)
return Quantity(ret, dim)
if not isinstance(out, Quantity):
raise TypeError("out parameter must be a Quantity")
out._dimensionality = dim
Expand All @@ -652,16 +657,15 @@ def trace(self, offset=0, axis1=0, axis2=1, dtype=None, out=None):
def squeeze(self, axis=None):
return Quantity(
self.magnitude.squeeze(axis),
self.dimensionality,
copy=False
self.dimensionality
)

@with_doc(np.ndarray.mean)
def mean(self, axis=None, dtype=None, out=None):
ret = self.magnitude.mean(axis, dtype, None if out is None else out.magnitude)
dim = self.dimensionality
if out is None:
return Quantity(ret, dim, copy=False)
return Quantity(ret, dim)
if not isinstance(out, Quantity):
raise TypeError("out parameter must be a Quantity")
out._dimensionality = dim
Expand All @@ -672,15 +676,14 @@ def nanmean(self, axis=None, dtype=None, out=None):
import numpy as np
return Quantity(
np.nanmean(self.magnitude, axis, dtype, out),
self.dimensionality,
copy=False)
self.dimensionality)

@with_doc(np.ndarray.var)
def var(self, axis=None, dtype=None, out=None, ddof=0):
ret = self.magnitude.var(axis, dtype, out, ddof)
dim = self._dimensionality**2
if out is None:
return Quantity(ret, dim, copy=False)
return Quantity(ret, dim)
if not isinstance(out, Quantity):
raise TypeError("out parameter must be a Quantity")
out._dimensionality = dim
Expand All @@ -691,7 +694,7 @@ def std(self, axis=None, dtype=None, out=None, ddof=0):
ret = self.magnitude.std(axis, dtype, out, ddof)
dim = self.dimensionality
if out is None:
return Quantity(ret, dim, copy=False)
return Quantity(ret, dim)
if not isinstance(out, Quantity):
raise TypeError("out parameter must be a Quantity")
out._dimensionality = dim
Expand All @@ -701,8 +704,7 @@ def std(self, axis=None, dtype=None, out=None, ddof=0):
def nanstd(self, axis=None, dtype=None, out=None, ddof=0):
return Quantity(
np.nanstd(self.magnitude, axis, dtype, out, ddof),
self._dimensionality,
copy=False
self._dimensionality
)

@with_doc(np.ndarray.prod)
Expand All @@ -715,7 +717,7 @@ def prod(self, axis=None, dtype=None, out=None):
ret = self.magnitude.prod(axis, dtype, None if out is None else out.magnitude)
dim = self._dimensionality**power
if out is None:
return Quantity(ret, dim, copy=False)
return Quantity(ret, dim)
if not isinstance(out, Quantity):
raise TypeError("out parameter must be a Quantity")
out._dimensionality = dim
Expand All @@ -726,7 +728,7 @@ def cumsum(self, axis=None, dtype=None, out=None):
ret = self.magnitude.cumsum(axis, dtype, None if out is None else out.magnitude)
dim = self.dimensionality
if out is None:
return Quantity(ret, dim, copy=False)
return Quantity(ret, dim)
if not isinstance(out, Quantity):
raise TypeError("out parameter must be a Quantity")
out._dimensionality = dim
Expand All @@ -743,7 +745,7 @@ def cumprod(self, axis=None, dtype=None, out=None):
ret = self.magnitude.cumprod(axis, dtype, out)
dim = self.dimensionality
if out is None:
return Quantity(ret, dim, copy=False)
return Quantity(ret, dim)
if isinstance(out, Quantity):
out._dimensionality = dim
return out
Expand Down
7 changes: 6 additions & 1 deletion quantities/tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ def assertQuantityEqual(self, q1, q2, msg=None, delta=None):
Make sure q1 and q2 are the same quantities to within the given
precision.
"""
delta = 1e-5 if delta is None else delta
if delta is None:
# NumPy 2 introduced float16, so we base tolerance on machine epsilon
delta1 = np.finfo(q1.dtype).eps if isinstance(q1, np.ndarray) and q1.dtype.kind in 'fc' else 1e-15
delta2 = np.finfo(q2.dtype).eps if isinstance(q2, np.ndarray) and q2.dtype.kind in 'fc' else 1e-15
delta = max(delta1, delta2)**0.3
msg = '' if msg is None else ' (%s)' % msg

q1 = Quantity(q1)
Expand All @@ -28,6 +32,7 @@ def assertQuantityEqual(self, q1, q2, msg=None, delta=None):
raise self.failureException(
f"Shape mismatch ({q1.shape} vs {q2.shape}){msg}"
)

if not np.all(np.abs(q1.magnitude - q2.magnitude) < delta):
raise self.failureException(
"Magnitudes differ by more than %g (%s vs %s)%s"
Expand Down
27 changes: 2 additions & 25 deletions quantities/tests/test_arithmetic.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,32 +48,9 @@ def check(f, *args, **kwargs):
return (new, )


class iter_dtypes:

def __init__(self):
self._i = 1
self._typeDict = np.sctypeDict.copy()
self._typeDict[17] = int
self._typeDict[18] = long
self._typeDict[19] = float
self._typeDict[20] = complex

def __iter__(self):
return self

def __next__(self):
if self._i > 20:
raise StopIteration

i = self._i
self._i += 1
return self._typeDict[i]

def next(self):
return self.__next__()

def get_dtypes():
return list(iter_dtypes())
numeric_dtypes = 'iufc' # https://numpy.org/doc/stable/reference/generated/numpy.dtype.kind.html
return [v for v in np.sctypeDict.values() if np.dtype(v).kind in numeric_dtypes] + [int, long, float, complex]


class iter_types:
Expand Down
8 changes: 6 additions & 2 deletions quantities/tests/test_methods.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .. import units as pq
import warnings
from .. import QuantitiesDeprecationWarning, units as pq
from .common import TestCase
import numpy as np

Expand Down Expand Up @@ -119,7 +120,10 @@ def methodWithOut(self, name, result, q=None, *args, **kw):
)
if isinstance(result, Quantity):
# deliberately using an incompatible unit
out = Quantity(np.empty_like(result.magnitude), pq.s, copy=False)
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=QuantitiesDeprecationWarning)
out = Quantity(np.empty_like(result.magnitude), pq.s, copy=False)
# we can drop 'copy=False' above once the deprecation of the arg has expired.
else:
out = np.empty_like(result)
ret = getattr(q.copy(), name)(*args, out=out, **kw)
Expand Down
Loading
Loading