From efafac6c9df383c341c36893cfaa4c8965d2395b Mon Sep 17 00:00:00 2001 From: Omair Asdaq Date: Sat, 17 Oct 2026 19:10:57 +0500 Subject: [PATCH 1/6] fix: correct units and magnitudes in multiply --- src/quantity_array/__init__.py | 2 +- tests/test_array.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/quantity_array/__init__.py b/src/quantity_array/__init__.py index b4bc949..fb4fe3f 100644 --- a/src/quantity_array/__init__.py +++ b/src/quantity_array/__init__.py @@ -749,7 +749,7 @@ def multiply(x1, x2, /, *args, **kwargs): units = x1.units * x2.units x1_magnitude = xp.asarray(x1.magnitude, copy=True) - x2_magnitude = x2.m_as(x1.units) + x2_magnitude = x2.magnitude magnitude = xp.multiply(x1_magnitude, x2_magnitude, *args, **kwargs) return ArrayUnitQuantity(magnitude, units) diff --git a/tests/test_array.py b/tests/test_array.py index 6f3c478..8d94635 100644 --- a/tests/test_array.py +++ b/tests/test_array.py @@ -525,3 +525,20 @@ def test_tile(self): pxp.tile(pxp.asarray([[1, 2], [3, 4]]) * self.ureg.m, (2, 1)), pxp.asarray([[1, 2], [3, 4], [1, 2], [3, 4]]) * self.ureg.m, ) + + +class TestMultiplyUnits(TestArrayMethods): + def test_multiply_units(self): + result = pxp.multiply( + pxp.asarray([1.0], units="m"), pxp.asarray([1.0], units="mm") + ) + assert result.units == self.ureg.m * self.ureg.mm + np.testing.assert_allclose( + np.asarray(result.m_as("m**2"), dtype=float), [1e-3] + ) + + result = pxp.multiply( + pxp.asarray([2.0], units="m"), pxp.asarray([3.0], units="s") + ) + assert result.units == self.ureg.m * self.ureg.s + np.testing.assert_allclose(np.asarray(result.m_as("m*s"), dtype=float), [6.0]) From f5c84563b99f635be3a41f4dbdd2cbf6cb6de04e Mon Sep 17 00:00:00 2001 From: Omair Asdaq Date: Sat, 17 Oct 2026 19:10:57 +0500 Subject: [PATCH 2/6] fix: give divide and floor_divide quotient units --- src/quantity_array/__init__.py | 30 ++++++++++++++++++++++++++++-- tests/test_array.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/quantity_array/__init__.py b/src/quantity_array/__init__.py index fb4fe3f..11d4cf8 100644 --- a/src/quantity_array/__init__.py +++ b/src/quantity_array/__init__.py @@ -707,9 +707,7 @@ def _sqrt(x, /, *args, **kwargs): "bitwise_right_shift", "bitwise_xor", "copysign", - "divide", "equal", - "floor_divide", "greater", "greater_equal", "hypot", @@ -756,6 +754,34 @@ def multiply(x1, x2, /, *args, **kwargs): mod.multiply = multiply + def divide(x1, x2, /, *args, **kwargs): + x1 = asarray(x1) + x2 = asarray(x2) + + units = x1.units / x2.units + + x1_magnitude = xp.asarray(x1.magnitude, copy=True) + x2_magnitude = x2.magnitude + + magnitude = xp.divide(x1_magnitude, x2_magnitude, *args, **kwargs) + return ArrayUnitQuantity(magnitude, units) + + mod.divide = divide + + def floor_divide(x1, x2, /, *args, **kwargs): + x1 = asarray(x1) + x2 = asarray(x2) + + units = x1.units / x2.units + + x1_magnitude = xp.asarray(x1.magnitude, copy=True) + x2_magnitude = x2.magnitude + + magnitude = xp.floor_divide(x1_magnitude, x2_magnitude, *args, **kwargs) + return ArrayUnitQuantity(magnitude, units) + + mod.floor_divide = floor_divide + def pow(x1, x2, /, *args, **kwargs): x1 = asarray(x1) x2 = asarray(x2) diff --git a/tests/test_array.py b/tests/test_array.py index 8d94635..80d7a90 100644 --- a/tests/test_array.py +++ b/tests/test_array.py @@ -542,3 +542,31 @@ def test_multiply_units(self): ) assert result.units == self.ureg.m * self.ureg.s np.testing.assert_allclose(np.asarray(result.m_as("m*s"), dtype=float), [6.0]) + + +class TestDivideUnits(TestArrayMethods): + def test_divide_units(self): + result = pxp.divide( + pxp.asarray([2.0], units="m"), pxp.asarray([1.0], units="m") + ) + assert result.units == self.ureg.dimensionless + np.testing.assert_allclose(np.asarray(result.m_as(""), dtype=float), [2.0]) + + result = pxp.divide( + pxp.asarray([6.0], units="m"), pxp.asarray([3.0], units="s") + ) + assert result.units == self.ureg.m / self.ureg.s + np.testing.assert_allclose(np.asarray(result.m_as("m/s"), dtype=float), [2.0]) + + def test_floor_divide_units(self): + result = pxp.floor_divide( + pxp.asarray([7.0], units="m"), pxp.asarray([2.0], units="m") + ) + assert result.units == self.ureg.dimensionless + np.testing.assert_allclose(np.asarray(result.m_as(""), dtype=float), [3.0]) + + result = pxp.floor_divide( + pxp.asarray([6.0], units="m"), pxp.asarray([4.0], units="s") + ) + assert result.units == self.ureg.m / self.ureg.s + np.testing.assert_allclose(np.asarray(result.m_as("m/s"), dtype=float), [1.0]) From 48f0c7e79db9cb667313b655fe2bb0c0048c4997 Mon Sep 17 00:00:00 2001 From: Omair Asdaq Date: Sat, 17 Oct 2026 19:10:58 +0500 Subject: [PATCH 3/6] fix: give square squared units --- src/quantity_array/__init__.py | 9 ++++++++- tests/test_array.py | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/quantity_array/__init__.py b/src/quantity_array/__init__.py index 11d4cf8..a5d94f7 100644 --- a/src/quantity_array/__init__.py +++ b/src/quantity_array/__init__.py @@ -675,7 +675,6 @@ def where(condition, x1, x2, /): "signbit", "sin", "sinh", - "square", "tan", "tanh", "trunc", @@ -698,6 +697,14 @@ def _sqrt(x, /, *args, **kwargs): mod.sqrt = _sqrt + def _square(x, /, *args, **kwargs): + x = asarray(x) + magnitude = xp.asarray(x.magnitude, copy=True) + magnitude = xp.square(magnitude, *args, **kwargs) + return ArrayUnitQuantity(magnitude, x.units**2) + + mod.square = _square + elementwise_two_arrays = [ "add", "atan2", diff --git a/tests/test_array.py b/tests/test_array.py index 80d7a90..435d2f9 100644 --- a/tests/test_array.py +++ b/tests/test_array.py @@ -570,3 +570,10 @@ def test_floor_divide_units(self): ) assert result.units == self.ureg.m / self.ureg.s np.testing.assert_allclose(np.asarray(result.m_as("m/s"), dtype=float), [1.0]) + + +class TestSquareUnits(TestArrayMethods): + def test_square_units(self): + result = pxp.square(pxp.asarray([3.0], units="m")) + assert result.units == self.ureg.m**2 + np.testing.assert_allclose(np.asarray(result.m_as("m**2"), dtype=float), [9.0]) From caeb9d314fb001598605d4583b45f66b5e39bd09 Mon Sep 17 00:00:00 2001 From: Omair Asdaq Date: Tue, 20 Oct 2026 00:40:35 +0500 Subject: [PATCH 4/6] fix: floor divide quantities on a common scale floor_divide floored the raw magnitudes of its operands but labelled the result with the quotient units x1.units / x2.units. The quotient unit carries a scale factor that the raw floor division ignored, so whenever the two units differed by a scale factor the answer was wrong by exactly that factor: floor_divide(2 m, 3 cm) -> 0.0 meter / centimeter (should be 66) floor_divide(1 m, 1 mm) -> 1.0 meter / millimeter (should be 999) floor_divide(2 min, 3 s) -> 0.0 minute / second (should be 39) The result was also not invariant under re-expression of the operands: the same physical ratio gave 0, 66 or 666 depending on the units used. Floor division of two quantities is a count -- the number of whole right-hand-side units that fit in one left-hand-side unit -- so the operands have to be brought onto a common scale before flooring, and the result is dimensionless. That is what pint's Quantity.__floordiv__ and this module's own // operator do, and it is what divmod returns as its quotient. Convert the right-hand side into the left-hand side's units first, which also raises DimensionalityError for dimensionally incompatible units, and return a dimensionless count. The test that asserted floor_divide(6 m, 4 s) == 1.0 m/s pinned the divergent behaviour and is replaced by one that expects the error. The scale factor case, an invariance check, and a check that the function agrees with the // operator are added. --- src/quantity_array/__init__.py | 19 +++++++++++--- tests/test_array.py | 48 +++++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/quantity_array/__init__.py b/src/quantity_array/__init__.py index a5d94f7..6f430c8 100644 --- a/src/quantity_array/__init__.py +++ b/src/quantity_array/__init__.py @@ -779,13 +779,24 @@ def floor_divide(x1, x2, /, *args, **kwargs): x1 = asarray(x1) x2 = asarray(x2) - units = x1.units / x2.units - x1_magnitude = xp.asarray(x1.magnitude, copy=True) - x2_magnitude = x2.magnitude + x2_magnitude = xp.asarray(x2.magnitude, copy=True) + + if x1.units != x2.units: + # The units differ, so the magnitudes are not commensurate and + # have to be brought onto a common scale before flooring. + # m_as() raises DimensionalityError for dimensionally + # incompatible units, as pint's Quantity.__floordiv__ does. + # The conversion factor is generally fractional, so this is done + # in floating point. + x1_magnitude = xp.astype(x1_magnitude, xp.float64) + x2_magnitude = astype(x2, xp.float64).m_as(x1.units) magnitude = xp.floor_divide(x1_magnitude, x2_magnitude, *args, **kwargs) - return ArrayUnitQuantity(magnitude, units) + # Floor division of two quantities is a count, not a ratio: it is the + # number of whole right-hand-side units that fit in one left-hand-side + # unit, so the result is dimensionless. + return ArrayUnitQuantity(magnitude, None) mod.floor_divide = floor_divide diff --git a/tests/test_array.py b/tests/test_array.py index 435d2f9..194242d 100644 --- a/tests/test_array.py +++ b/tests/test_array.py @@ -533,9 +533,7 @@ def test_multiply_units(self): pxp.asarray([1.0], units="m"), pxp.asarray([1.0], units="mm") ) assert result.units == self.ureg.m * self.ureg.mm - np.testing.assert_allclose( - np.asarray(result.m_as("m**2"), dtype=float), [1e-3] - ) + np.testing.assert_allclose(np.asarray(result.m_as("m**2"), dtype=float), [1e-3]) result = pxp.multiply( pxp.asarray([2.0], units="m"), pxp.asarray([3.0], units="s") @@ -565,11 +563,49 @@ def test_floor_divide_units(self): assert result.units == self.ureg.dimensionless np.testing.assert_allclose(np.asarray(result.m_as(""), dtype=float), [3.0]) + def test_floor_divide_scale_factor(self): + # The units differ by a scale factor, so the magnitudes have to be + # brought onto a common scale before flooring: 2 m is 200 cm, and + # 200 cm // 3 cm is 66. result = pxp.floor_divide( - pxp.asarray([6.0], units="m"), pxp.asarray([4.0], units="s") + pxp.asarray([2.0], units="m"), pxp.asarray([3.0], units="cm") ) - assert result.units == self.ureg.m / self.ureg.s - np.testing.assert_allclose(np.asarray(result.m_as("m/s"), dtype=float), [1.0]) + assert result.units == self.ureg.dimensionless + np.testing.assert_allclose(np.asarray(result.m_as(""), dtype=float), [66.0]) + + def test_floor_divide_scale_factor_is_invariant(self): + # The same physical ratio written with different units must give the + # same answer. + results = [ + pxp.floor_divide( + pxp.asarray([2.0], units="m"), pxp.asarray([3.0], units="cm") + ), + pxp.floor_divide( + pxp.asarray([200.0], units="cm"), pxp.asarray([3.0], units="cm") + ), + pxp.floor_divide( + pxp.asarray([2000.0], units="mm"), pxp.asarray([3.0], units="cm") + ), + ] + for result in results: + assert result.units == self.ureg.dimensionless + np.testing.assert_allclose(np.asarray(result.m_as(""), dtype=float), [66.0]) + + def test_floor_divide_matches_operator(self): + # The function and the operator must agree. + x1 = pxp.asarray([2.0], units="m") + x2 = pxp.asarray([3.0], units="cm") + np.testing.assert_allclose( + np.asarray(pxp.floor_divide(x1, x2).magnitude, dtype=float), + np.asarray(x1 // x2, dtype=float), + ) + + def test_floor_divide_incompatible_units(self): + # Dimensionally incompatible units cannot be floor divided. + with pytest.raises(DimensionalityError): + pxp.floor_divide( + pxp.asarray([6.0], units="m"), pxp.asarray([4.0], units="s") + ) class TestSquareUnits(TestArrayMethods): From d538310f74360a9c676378bdc6100f66eaa2f582 Mon Sep 17 00:00:00 2001 From: Omair Asdaq Date: Tue, 20 Oct 2026 08:32:32 +0500 Subject: [PATCH 5/6] fix: support a tuple axis in prod prod computed the unit exponent as magnitude.shape[axis], which raises TypeError when axis is a tuple of axes, because a tuple cannot index the shape. The array API allows a tuple of axes, and the magnitude itself was already computed correctly, so only the unit was wrong. Reduce over each axis and multiply the sizes together to get the number of elements reduced, which is the exponent the unit has to be raised to. For a single axis and for axis=None the result is unchanged. --- src/quantity_array/__init__.py | 11 ++++++++++- tests/test_array.py | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/quantity_array/__init__.py b/src/quantity_array/__init__.py index 6f430c8..e846c2a 100644 --- a/src/quantity_array/__init__.py +++ b/src/quantity_array/__init__.py @@ -975,7 +975,16 @@ def var(x, /, *args, **kwargs): def prod(x, /, *args, axis=None, **kwargs): x = asarray(x) magnitude = xp.asarray(x.magnitude, copy=True) - exponent = magnitude.shape[axis] if axis is not None else size(magnitude) + if axis is None: + exponent = size(magnitude) + elif isinstance(axis, tuple): + # The product reduces over every given axis, so the unit is + # raised to the power of the total number of elements reduced. + exponent = 1 + for axis_i in axis: + exponent *= magnitude.shape[axis_i] + else: + exponent = magnitude.shape[axis] units = x.units**exponent magnitude = xp.prod(magnitude, *args, axis=axis, **kwargs) return ArrayUnitQuantity(magnitude, units) diff --git a/tests/test_array.py b/tests/test_array.py index 194242d..92c8775 100644 --- a/tests/test_array.py +++ b/tests/test_array.py @@ -613,3 +613,24 @@ def test_square_units(self): result = pxp.square(pxp.asarray([3.0], units="m")) assert result.units == self.ureg.m**2 np.testing.assert_allclose(np.asarray(result.m_as("m**2"), dtype=float), [9.0]) + + +class TestProdUnits(TestArrayMethods): + def test_prod_units(self): + result = pxp.prod(pxp.asarray([[1.0, 2.0], [3.0, 4.0]], units="m")) + assert result.units == self.ureg.m**4 + np.testing.assert_allclose(np.asarray(result.m_as("m**4"), dtype=float), [24.0]) + + def test_prod_units_axis(self): + result = pxp.prod(pxp.asarray([[1.0, 2.0], [3.0, 4.0]], units="m"), axis=0) + assert result.units == self.ureg.m**2 + np.testing.assert_allclose( + np.asarray(result.m_as("m**2"), dtype=float), [3.0, 8.0] + ) + + def test_prod_units_tuple_axis(self): + # Reducing over several axes raises the unit to the power of the + # total number of elements reduced. + result = pxp.prod(pxp.asarray([[1.0, 2.0], [3.0, 4.0]], units="m"), axis=(0, 1)) + assert result.units == self.ureg.m**4 + np.testing.assert_allclose(np.asarray(result.m_as("m**4"), dtype=float), [24.0]) From 87bf40cce089455d0095894cfaee677a4f55743f Mon Sep 17 00:00:00 2001 From: Omair Asdaq Date: Tue, 20 Oct 2026 08:32:32 +0500 Subject: [PATCH 6/6] ci: pin the array-api-tests checkout The xp-tests task cloned array-api-tests from master every run, so the suite moved under the pinned array-api-strict. Tests that the pinned backend does not implement yet -- finfo/iinfo with an array argument, added in array-api-tests#358 -- then failed, and CI had been red for every pull request since. Pin the checkout to the last commit that the pinned array-api-strict passes, so that a failure in this job means a real regression. --- pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index db42c57..186a1ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,10 @@ array-api-strict = "*" # clean array-api-tests dir clean-xp-tests = { cwd = ".", cmd = "rm -rf array-api-tests" } # clone array-api-tests +# The checkout is pinned: the test suite tracks the array API standard and +# regularly adds tests that the pinned array-api-strict does not implement yet +# (finfo/iinfo with an array argument, for example). Pinning keeps the suite +# in step with array-api-strict so that a failure means a real regression. clone-xp-tests.cmd = [ "git", "clone", @@ -78,6 +82,10 @@ clone-xp-tests.cmd = [ "array-api-tests", "&&", "git", + "checkout", + "c48410f96fc58e02eea844e6b7f6cc01680f77ce", + "&&", + "git", "submodule", "update", "--init",