Skip to content

Fix units returned by multiply, divide/floor_divide, and square - #61

Open
Omair-Asdaq wants to merge 6 commits into
quantity-dev:mainfrom
Omair-Asdaq:fix/unit-arithmetic
Open

Omair-Asdaq wants to merge 6 commits into
quantity-dev:mainfrom
Omair-Asdaq:fix/unit-arithmetic

Conversation

@Omair-Asdaq

@Omair-Asdaq Omair-Asdaq commented Oct 17, 2026 •

Copy link
Copy Markdown

Three elementwise functions build their result's units incorrectly. I noticed it while checking that the function forms agree with the operator forms.

multiply — converted magnitude, unconverted units

multiply computes units = x1.units * x2.units but feeds the second operand through x2.m_as(x1.units). The magnitude ends up expressed in x1.units while the units are labelled as the product:

  • multiply(1 m, 1 mm) → 1e-06 m², should be 1e-03 m² (off by the conversion factor)
  • multiply(1 m, 1 s) → DimensionalityError, should give m·s

Using the raw magnitude together with the product of the two units agrees with the * operator, which was already correct.

divide — result carried the dividend's units

divide came out of the generic elementwise loop, which sets units = x1.units and converts x2 into x1.units:

  • divide(2 m, 1 m) → 2 m, should be dimensionless 2
  • divide(6 m, 3 s) → DimensionalityError, should be 2 m/s

It now has its own implementation using x1.units / x2.units, mirroring the way multiply is already special-cased.

square — units not squared

square came from the generic single-array loop, which returns the input units, so square(3 m) had units m. sqrt already special-cases this (x.units**0.5); square now returns x.units**2.

floor_divide — floored on incompatible scales

floor_divide has its own implementation using x1.units / x2.units, mirroring the way multiply is already special-cased.

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. This is what pint's Quantity.__floordiv__ does, what this module's own // operator does, and what divmod returns as its quotient. The operands are therefore converted onto a common scale first, which also raises DimensionalityError for dimensionally incompatible units.

prod — tuple axis

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 was already computed correctly, so only the unit was wrong. The exponent is now the product of the sizes of the reduced axes.

Tests

One regression test class per fix, asserting both the units and the numeric magnitude (normalised through m_as). All of them fail on the current master and pass with these changes. Full suite: 64 passed, 4 xfailed — the same 4 that are xfailed today.

I ran the suite against the array-api-strict==2.2 pinned in pixi.lock; a newer release rejects a list for shape and makes six unrelated tests fail.

CI

The xp-tests task cloned array-api-tests from master on 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. The checkout is now pinned to the last commit the pinned array-api-strict passes, so a failure in that job means a real regression.

Two things I noticed but did not change

  • The // operator strips units entirely and raises for dimensionally different operands, because __floordiv__ is bound by the generic unitless_binary_names loop. That is pre-existing and separate, but it means the new floor_divide can't agree with // until it's addressed.
  • Now that the units come from Unit arithmetic, multiply(1 degC, 1 m) and divide(1 degC, 2 s) return degC·m and degC/s where they previously raised. That is consistent with how the module already handles operation-defined units (prod, var, sqrt), and with pint's own array-function path, but it is a behaviour change worth a look.

@Omair-Asdaq
Omair-Asdaq marked this pull request as ready for review October 17, 2026 18:47
Copilot AI balanced review requested due to automatic review settings October 17, 2026 18:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@Omair-Asdaq
Omair-Asdaq marked this pull request as draft October 19, 2026 19:28
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.
@Omair-Asdaq Omair-Asdaq changed the title Fix units returned by multiply, divide/floor_divide, and square Draft: Fix units returned by multiply, divide/floor_divide, and square Oct 19, 2026
@Omair-Asdaq
Omair-Asdaq marked this pull request as ready for review October 20, 2026 03:09
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.
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.
@Omair-Asdaq
Omair-Asdaq marked this pull request as draft October 20, 2026 03:33
@Omair-Asdaq Omair-Asdaq changed the title Draft: Fix units returned by multiply, divide/floor_divide, and square Fix units returned by multiply, divide/floor_divide, and square Oct 20, 2026
@Omair-Asdaq
Omair-Asdaq marked this pull request as ready for review October 20, 2026 14:31

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants