From 09532d85f19616621ce1aa43e635b08664d9a130 Mon Sep 17 00:00:00 2001 From: nstarman Date: Fri, 7 Aug 2026 20:08:24 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(distances):=20promote=20`Abstr?= =?UTF-8?q?actDistance`=20against=20`ParametricQuantity`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ParametricQuantity` is not a `unxt.Q` subclass, so the `AbstractDistance`/`Q` promotion rules in `distances/_src/base.py` never reach it. `Distance(1, "pc") * PQ(1.0, "rad")` dispatches to the `Distance`-returning multiply and raises `Distance must have dimensions length`, while the mirrored `PQ * Distance` returns a `PQ` -- the operand order decides whether the expression works. The rule goes in the existing `register_parametric`, already imported behind `OptDeps.UNXTS_PARAMETRIC.installed`, and spells the type `PQ` to match the `from_` overloads above it -- `PQ is ParametricQuantity`. Adds the matching `parametric` extra, which the module had been relying on the test group to supply. This is the core half of #672, which fixed the same defect for `coordinaxs.astro`'s `Parallax` and `DistanceModulus`. Those are `AbstractDistance` subclasses, so this subsumes them; astro's stay, since it must work against a `coordinax` predating this, and plum takes the more specific rule where both apply. Only reachability changes, not values: `PQ * Distance` is `PQ(1., 'pc rad')` before and after, and `Distance * PQ` now returns that instead of raising. `tests/unit/distances`: 66 passed. `packages/coordinaxs.astro`: 406 passed, 2 skipped. With the distribution hidden from `importlib.metadata`, `coordinax.distances` imports without pulling in `unxts.parametric`. pre-commit clean. Co-Authored-By: Claude Opus 5 --- pyproject.toml | 1 + .../distances/_src/register_parametric.py | 13 ++++++++++++- tests/unit/distances/test_parametric.py | 19 +++++++++++++++++++ uv.lock | 6 +++++- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4d4fe9d3..94b5d378 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ astro = ["coordinaxs.astro>=0.24"] benchmark = ["pytest-benchmark>=5.2.3", "pytest-codspeed>=5.0.3"] curveframes = ["coordinaxs.curveframes>=0.24"] interop-astropy = ["coordinaxs.interop.astropy>=0.24"] +parametric = ["unxts.parametric>=2.0"] workspace = [ "coordinaxs.api>=0.24", "coordinaxs.astro>=0.24", diff --git a/src/coordinax/distances/_src/register_parametric.py b/src/coordinax/distances/_src/register_parametric.py index cd347edf..50effc57 100644 --- a/src/coordinax/distances/_src/register_parametric.py +++ b/src/coordinax/distances/_src/register_parametric.py @@ -1,18 +1,24 @@ -"""`from_` overloads for `unxts.parametric`; imported only when it is installed. +"""Registrations for `unxts.parametric`; imported only when it is installed. A `ParametricQuantity["length"|"angle"|"mag"]` carries its physical type in the *type*, so plum can pick the branch statically and prefers these over the `AbstractQuantity` catch-all in `measures`, which has to read `u.dimension_of(q)` at runtime. Plain `unxt.Quantity` still takes that path. + +It is also not a `unxt.Q` subclass, so the `AbstractDistance`/`Q` promotion +rules in `base` never reach it; the rule below closes that. """ __all__: tuple[str, ...] = () from typing import Any +from plum import add_promotion_rule + # Optional dependency: absent from the lint environment by design. from unxts.parametric import PQ # ty: ignore[unresolved-import] +from .base import AbstractDistance from .measures import Distance, _from_angle, _from_length, _from_mag @@ -32,3 +38,8 @@ def from_(cls: type[Distance], q: PQ["angle"], /, **kw: Any) -> Distance: def from_(cls: type[Distance], q: PQ["mag"], /, **kw: Any) -> Distance: """Construct a distance from a parametric magnitude quantity.""" return _from_mag(cls, q, **kw) + + +# Degrade to the parametric quantity, as the `AbstractDistance`/`Q` rules in +# `base` do. +add_promotion_rule(AbstractDistance, PQ, PQ) diff --git a/tests/unit/distances/test_parametric.py b/tests/unit/distances/test_parametric.py index cc4d48cf..72d8b999 100644 --- a/tests/unit/distances/test_parametric.py +++ b/tests/unit/distances/test_parametric.py @@ -5,6 +5,7 @@ """ import jax.numpy as jnp +import plum import pytest import unxt as u @@ -69,3 +70,21 @@ def test_dispatches_by_type_not_by_dimension(self, value: float, unit: str) -> N parametric = _resolved_from_(cxd.Distance, PQ(value, unit)) plain = _resolved_from_(cxd.Distance, u.Q(value, unit)) assert parametric is not plain + + +class TestParametricPromotion: + """A distance and a `PQ` promote to the `PQ`.""" + + def test_promotes_to_parametric_quantity(self) -> None: + """`plum.promote` degrades the distance, as it does for `Q`.""" + promoted = plum.promote(cxd.Distance(1, "pc"), PQ(1.0, "rad")) + assert all(isinstance(x, PQ) for x in promoted) + + def test_arithmetic_is_order_independent(self) -> None: + """Both operand orders give the same `PQ`; `Distance * PQ` used to raise.""" + d, pq = cxd.Distance(1, "pc"), PQ(1.0, "rad") + forward, reverse = d * pq, pq * d + assert isinstance(forward, PQ) + assert isinstance(reverse, PQ) + assert forward.unit == reverse.unit + assert jnp.allclose(forward.value, reverse.value) diff --git a/uv.lock b/uv.lock index 45dd457f..cc1c4f8a 100644 --- a/uv.lock +++ b/uv.lock @@ -487,6 +487,9 @@ curveframes = [ interop-astropy = [ { name = "coordinaxs-interop-astropy" }, ] +parametric = [ + { name = "unxts-parametric" }, +] workspace = [ { name = "coordinaxs-api" }, { name = "coordinaxs-astro" }, @@ -627,10 +630,11 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.13.2" }, { name = "unxt", specifier = ">=2.0" }, { name = "unxts-linalg", specifier = ">=2.0.3" }, + { name = "unxts-parametric", marker = "extra == 'parametric'", specifier = ">=2.0" }, { name = "wadler-lindig", specifier = ">=0.1.6" }, { name = "xmmutablemap", specifier = ">=0.1" }, ] -provides-extras = ["astro", "benchmark", "curveframes", "interop-astropy", "workspace"] +provides-extras = ["astro", "benchmark", "curveframes", "interop-astropy", "parametric", "workspace"] [package.metadata.requires-dev] build = [{ name = "build", specifier = ">=1.3.0" }]