Skip to content

🐛 fix(quantity): __array__ must not silently strip units - #892

Merged
nstarman merged 1 commit into
GalacticDynamics:mainfrom
nstarman:claude/array-strips-units
Aug 17, 2026
Merged

🐛 fix(quantity): __array__ must not silently strip units#892
nstarman merged 1 commit into
GalacticDynamics:mainfrom
nstarman:claude/array-strips-units

Conversation

@nstarman

Copy link
Copy Markdown
Contributor

A dimensionful Quantity handed to np.asarray / jnp.asarray came back as a bare number in whatever unit it happened to carry:

>>> np.asarray(u.Q(1.5, "km"))    # -> 1.5
>>> np.asarray(u.Q(1500, "m"))    # -> 1500      the same length
>>> np.asarray(u.Q(90.0, "deg"))  # -> 90.0      read as radians, a 57x error

The consumer gets a number whose meaning depends on a unit it never saw, and nothing anywhere raises. That is the one outcome a units library must not produce.

Why now

__array__ is reached implicitly. np.asarray has always used it; jax.numpy.asarray began doing so in jax 0.10, where it previously raised TypeError. Isolated with clean venvs:

unxt jax jnp.asarray(Q(1.5,"km"))
2.0.0 0.7.2 TypeError
2.0.0 0.10.0 Array(1.5) — stripped
2.0.1 0.7.2 TypeError
2.0.1 0.10.0 Array(1.5) — stripped

So the trigger is the jax upgrade and the bug predates 2.0.1 — but the cause is here, and jax is entitled to honour __array__.

This is not new knowledge in the codebase. base.py already documents __array__ silently dropping units for astropy operands and works around it for that one case:

quax would materialise it via __array__ -- stripping it to a bare array in its own unit -- so *// silently dropped its unit

This closes the general case.

The change

__array__ refuses a dimensionful quantity with UnitConversionError, naming ustrip as the explicit route. Dimensionless still converts — there is nothing to lose.

UnitConversionError rather than TypeError is deliberate: try: np.asarray(x) except TypeError: is a common shape, and a TypeError here would be swallowed into some other silent fallback. A units error will not be mistaken for "not array-like".

Angles raise too. rad/deg are dimension angle, not dimensionless, and Q(90, "deg") -> 90.0 is precisely the hazard.

One real consumer relied on the strip

Dataset.unxt.quantify() broke on any dataset with a unitful dimension coordinate — time in seconds, say. It was attaching a Quantity to a dimension coordinate and letting xarray's pandas.Index coercion discard it: the anti-pattern itself.

It now skips dimension coordinates explicitly. Identical result, chosen deliberately rather than left to __array__. The guide's "Dimension Coordinates Cannot Hold Quantities" section described the silent loss and now describes the refusal.

Verification

  • Full suite: 4058 passed, 49 skipped, 20 xfailed
  • Blast radius before fixing consumers was 3 of 4054, and all three were the fix working
  • New regression test pins m/km/deg/rad across both np.asarray and jax.numpy.asarray, with the 57° hazard recorded so it is not quietly re-loosened

Release

Worth backporting to versions/v2.0.x. The previous behaviour was the defect rather than an interface anyone could correctly depend on, so this belongs in a patch rather than waiting on a minor.

Found from downstream: GalacticDynamics/coordinax#720 had five jnp.asarray(<Quantity>) call sites that passed on new jax only because units were being discarded.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 17, 2026 14:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@github-actions github-actions Bot added ✅ Add / update / pass tests Add, update, or pass tests. 🧩 unxts-interop-xarray Issues/PRs affecting the unxts.interop.xarray namespace package 🐛 Fix a bug Fix a bug. labels Aug 17, 2026
nstarman added a commit to nstarman/coordinax that referenced this pull request Aug 17, 2026
The oldest-dependencies job failed all seven `TestChordDistance` cases with

    TypeError: Unexpected input type for array: Quantity

`chord_distance` returns a dimensionless `Quantity`, and the tests fed it to
raw `jax.numpy.asarray`. On jax 0.7.2 that raises; on jax 0.10+ it succeeds by
reaching `Quantity.__array__` -- which discards the unit. The failing job was
the honest one: the tests passed elsewhere only because units were being thrown
away silently.

`.ustrip("")` at the five call sites instead, naming the unit expected. Correct
on every version, and once GalacticDynamics/unxt#892 lands the old spelling
raises everywhere rather than stripping.

This should also clear `codecov/project`: that job uploads the coverage report,
so its failure withheld it. `chord_distance.py` is at 100% and `codecov/patch`
passed throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.86%. Comparing base (c5a9d8d) to head (40f2b7f).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff             @@
##              main     #892      +/-   ##
===========================================
- Coverage   100.00%   99.86%   -0.14%     
===========================================
  Files            4       50      +46     
  Lines           25     2916    +2891     
  Branches         0      207     +207     
===========================================
+ Hits            25     2912    +2887     
- Misses           0        2       +2     
- Partials         0        2       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

nstarman added a commit to nstarman/coordinax that referenced this pull request Aug 17, 2026
The oldest-dependencies job failed all seven `TestChordDistance` cases with

    TypeError: Unexpected input type for array: Quantity

`chord_distance` returns a dimensionless `Quantity`, and the tests fed it to
raw `jax.numpy.asarray`. On jax 0.7.2 that raises; on jax 0.10+ it succeeds by
reaching `Quantity.__array__` -- which discards the unit. The failing job was
the honest one: the tests passed elsewhere only because units were being thrown
away silently.

`.ustrip("")` at the five call sites instead, naming the unit expected. Correct
on every version, and once GalacticDynamics/unxt#892 lands the old spelling
raises everywhere rather than stripping.

This should also clear `codecov/project`: that job uploads the coverage report,
so its failure withheld it. `chord_distance.py` is at 100% and `codecov/patch`
passed throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nstarman nstarman added this to the v2.0.x milestone Aug 17, 2026
`Quantity.__array__` returned the bare value in whatever unit the quantity
happened to carry, so a consumer received a number whose meaning depended on a
unit it never saw:

    np.asarray(Q(1.5, "km"))   -> 1.5
    np.asarray(Q(1500, "m"))   -> 1500      # the same length
    np.asarray(Q(90.0, "deg")) -> 90.0      # 57x, to a radian consumer

`__array__` is reached implicitly, which is what makes this dangerous rather
than merely surprising. `np.asarray` has always used it, and `jax.numpy.asarray`
began doing so in jax 0.10 -- it raised `TypeError` before. Isolated: unxt 2.0.0
with jax 0.10 strips; unxt 2.0.1 with jax 0.7.2 raises. So the trigger is the
jax upgrade, but the cause is here.

Refuse a dimensionful quantity with `UnitConversionError`, naming `ustrip` as
the explicit route. Dimensionless still converts -- there is nothing to lose.
`UnitConversionError` rather than `TypeError` deliberately: callers write
`try: np.asarray(x) except TypeError` and fall back, which would swallow this
into a different silent path.

unxt already knew the hazard: `base.py` documents `__array__` dropping units
for astropy operands and works around it there. This closes the general case.

One real consumer relied on the strip. `Dataset.unxt.quantify()` attached a
Quantity to a *dimension* coordinate and let xarray's `pandas.Index` coercion
discard it -- exactly the anti-pattern. It now skips dimension coordinates
explicitly: same result, chosen rather than left to `__array__`. The guide's
"Dimension Coordinates Cannot Hold Quantities" section documented the silent
loss and now documents the refusal.

Suggest backporting to versions/v2.0.x: the previous behaviour was the defect,
not an interface anyone could correctly depend on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (3)

packages/unxts.interop.xarray/src/unxts/interop/xarray/_src/conversion.py:433

  • This comment still refers to Quantity.__array__ “discard[ing] silently”, but in this PR Quantity.__array__ now raises for dimensionful quantities. The rationale here is to avoid triggering that refusal and to keep dimension coordinates plain to match xarray’s indexing model; updating the wording will prevent future confusion.
        # Quantity there cannot survive, so do not attach one: skipping is the
        # same outcome xarray would reach anyway, but chosen here rather than
        # left to `Quantity.__array__` to discard silently. See "Dimension
        # Coordinates Cannot Hold Quantities" in the guide.

src/unxt/_src/quantity/mixins.py:232

  • The docstring says “Dimensionless quantities convert”, but the implementation only permits the unscaled dimensionless unit (dimensionless_unscaled / unit string ""). Scaled dimensionless units like "percent" will still raise unless explicitly converted, so the docstring should be more precise to avoid misleading API users.
        Dimensionless quantities convert, because there is nothing to lose.

packages/unxts.interop.xarray/src/unxts/interop/xarray/_src/conversion.py:372

  • This comment still refers to Quantity.__array__ “discard[ing] silently”, but in this PR Quantity.__array__ now raises for dimensionful quantities. The rationale here is to avoid triggering that refusal and to keep dimension coordinates plain to match xarray’s indexing model; updating the wording will prevent future confusion.

This issue also appears on line 430 of the same file.

        # Quantity there cannot survive, so do not attach one: skipping is the
        # same outcome xarray would reach anyway, but chosen here rather than
        # left to `Quantity.__array__` to discard silently. See "Dimension
        # Coordinates Cannot Hold Quantities" in the guide.

@nstarman
nstarman merged commit 912a467 into GalacticDynamics:main Aug 17, 2026
36 of 37 checks passed
@nstarman
nstarman deleted the claude/array-strips-units branch August 17, 2026 16:39
nstarman added a commit that referenced this pull request Aug 17, 2026
…p units (#896)

Co-authored-by: Nathaniel Starkman <nstarman@users.noreply.github.com>
nstarman added a commit to nstarman/coordinax that referenced this pull request Aug 17, 2026
unxt 2.0.2 (GalacticDynamics/unxt#892) makes `Quantity.__array__` raise for
anything dimensionful instead of silently returning a bare array, so raise the
floor to `>=2.0.2` and fix what that exposes.

`_apply_jac` branched on *all* components being a Quantity, so a mixed CDict
fell through to the bare-array path and had its units stripped, returning a
silently wrong number. `norm` and `quadratic_form` already reject that case;
`tangent_map` was the sole gap. Reuse their wording.

`test_usys_is_forwarded_to_embed` asserted through `np.asarray(out["x"])`,
discarding the unit it exists to verify — it would have passed on 2.0 km. Now
`u.ustrip("m", ...)`, matching every other assertion in the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nstarman added a commit to GalacticDynamics/coordinax that referenced this pull request Aug 17, 2026
Raise the `unxt` floor to `>=2.0.2`. Two newer-dependency changes each turn a
silent bug loud, and both are fixed here.

1. quax metaclass `__call__` (`-> Any` → `-> _T`, pulled in by unxt 2.0.1's
   `quax>=0.4.2` floor) restores the `dataclass_transform` signature for every
   `Value` subclass, surfacing 14 latent constructor errors. Twelve are one
   defect: `AbstractVector` declared `rep` as an `eqx.AbstractVar` instance
   attribute while every concrete subclass *derives* it as a `@property`
   (`Point` returns a constant, `Tangent` builds one from its static
   `basis`/`semantic` fields) — it was never an `__init__` parameter. It is an
   abstract property now. The rest: name `basis=`/`semantic=` at the `Tangent`
   call in the astropy interop, where ty mis-maps positionally on the generic
   dataclass; keep a `ty: ignore` on `type(x)(..., check_negative=...)`, which
   ty cannot narrow through the `getattr` guard above it.

2. unxt 2.0.2 (GalacticDynamics/unxt#892) makes `Quantity.__array__` raise for
   anything dimensionful rather than silently returning a bare array. That
   exposed `_apply_jac` branching on *all* components being a `Quantity`: a
   mixed CDict failed `all(...)`, fell through to the bare-array path, and had
   its units stripped — a silently wrong result. `norm` and `quadratic_form`
   already reject that case; `tangent_map` was the sole gap, now closed with
   their wording. Dimensionless `Quantity` mixed with bare arrays is rejected
   too, deliberately, for consistency across the three call sites.

Also: `test_usys_is_forwarded_to_embed` asserted through `np.asarray(out["x"])`,
discarding the unit it exists to verify (it would have passed on 2.0 km) — now
`u.ustrip("m", ...)`, matching the rest of the file. Three `frames` doctests
updated for a cosmetic pretty-print change: scalar `Quantity` now prints
`Q(10, 'm')` rather than `Q(i64[], 'm')`; large arrays still summarise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✅ Add / update / pass tests Add, update, or pass tests. 🐛 Fix a bug Fix a bug. 🧩 unxts-interop-xarray Issues/PRs affecting the unxts.interop.xarray namespace package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants