🐛 fix(quantity): __array__ must not silently strip units - #892
Merged
nstarman merged 1 commit intoAug 17, 2026
Conversation
Copilot stopped reviewing on behalf of
nstarman due to an error
August 17, 2026 14:03
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 Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
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>
`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>
nstarman
force-pushed
the
claude/array-strips-units
branch
from
August 17, 2026 16:19
7ccc468 to
40f2b7f
Compare
Contributor
There was a problem hiding this comment.
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 PRQuantity.__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 PRQuantity.__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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A dimensionful
Quantityhanded tonp.asarray/jnp.asarraycame back as a bare number in whatever unit it happened to carry: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.asarrayhas always used it;jax.numpy.asarraybegan doing so in jax 0.10, where it previously raisedTypeError. Isolated with clean venvs:jnp.asarray(Q(1.5,"km"))TypeErrorArray(1.5)— strippedTypeErrorArray(1.5)— strippedSo 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.pyalready documents__array__silently dropping units for astropy operands and works around it for that one case:This closes the general case.
The change
__array__refuses a dimensionful quantity withUnitConversionError, namingustripas the explicit route. Dimensionless still converts — there is nothing to lose.UnitConversionErrorrather thanTypeErroris deliberate:try: np.asarray(x) except TypeError:is a common shape, and aTypeErrorhere would be swallowed into some other silent fallback. A units error will not be mistaken for "not array-like".Angles raise too.
rad/degare dimension angle, not dimensionless, andQ(90, "deg") -> 90.0is precisely the hazard.One real consumer relied on the strip
Dataset.unxt.quantify()broke on any dataset with a unitful dimension coordinate —timein seconds, say. It was attaching aQuantityto a dimension coordinate and letting xarray'spandas.Indexcoercion 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
m/km/deg/radacross bothnp.asarrayandjax.numpy.asarray, with the 57° hazard recorded so it is not quietly re-loosenedRelease
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