From 3be839661277a154128ff77711cba6e5398c557a Mon Sep 17 00:00:00 2001 From: nstarman Date: Fri, 14 Aug 2026 14:48:59 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat(manifolds):=20add=20`chord?= =?UTF-8?q?=5Fdistance`,=20the=20straight=20line=20through=20the=20ambient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `geodesic_distance` measures along the manifold. The other honest answer is the straight line *through* the space it is embedded in -- the tunnel rather than the surface path. On a sphere of radius R separated by a central angle t: geodesic = R t, chord = 2 R sin(t / 2) They agree to first order and diverge as the points separate: 0.927 against 0.964 at t = 1 on the unit sphere, and 2R against pi R at antipodes. Which one is wanted depends on the question -- a great-circle flight path is the geodesic, a line of sight through the body is the chord -- so it is a separate verb rather than a mode of the first. Implemented by embedding both points and taking the ambient manifold's `geodesic_distance`, which for flat ambient space *is* the straight line. There is no second distance formula to keep in step, and it works for any embedding, not only spheres. A Euclidean manifold is refused: it is its own ambient, so its chord is its geodesic, and returning the same number under a second name invites the reader to think two things were measured. A manifold with no embedding is refused too. Matches `2 R sin(t / 2)` to 1.1e-16, symmetric and chart-invariant to 1e-16. No fast path, deliberately: ~99.5% of an eager call is the ambient `geodesic_distance`, and ~0.5% the embedding steps a shortcut could skip. Co-Authored-By: Claude Opus 5 --- docs/api/manifolds.md | 1 + .../src/coordinaxs/api/manifolds.py | 12 + src/coordinax/__init__.py | 2 + src/coordinax/_src/manifolds/__init__.py | 1 + .../_src/manifolds/chord_distance.py | 236 ++++++++++++++++++ .../_src/manifolds/geodesic_distance.py | 60 ++++- src/coordinax/manifolds/__init__.py | 2 + .../test_geodesic_distance_dispatch.py | 68 +++++ 8 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 src/coordinax/_src/manifolds/chord_distance.py diff --git a/docs/api/manifolds.md b/docs/api/manifolds.md index 873edcf62..b2fe07d3c 100644 --- a/docs/api/manifolds.md +++ b/docs/api/manifolds.md @@ -49,6 +49,7 @@ ang = cxm.angle_between(cxc.cart3d, uvec, vvec, at=at) - `angle_between`: return the metric angle between two tangent-vector CDicts - `norm`: compute the Riemannian norm $\|v\|_g = \sqrt{g_p(v,v)}$ of a tangent vector in a chart. Requires a **positive-definite** metric; raises `NotImplementedError` for an indefinite one (e.g. Minkowski), where the square root would be `nan` - `geodesic_distance`: length of the shortest path between two points _along the manifold_. Symmetric and chart-invariant, computed from the manifold's geometry: the straight line in flat space, the great circle on a sphere. A manifold with no closed-form geodesic raises `NotImplementedError` rather than approximating, as does Minkowski, whose indefinite metric admits no distance +- `chord_distance`: length of the straight line between two points _through the ambient space_ they are embedded in -- the tunnel rather than the surface path. A different measurement from `geodesic_distance`, not an approximation to it: on a sphere of radius $R$ separated by a central angle $\theta$, the geodesic is $R\theta$ and the chord is $2R\sin(\theta/2)$. Needs an embedding, so a manifold that is its own ambient (anything Euclidean) raises `NotImplementedError` and points at `geodesic_distance` - `interval`: signed squared interval $\Delta s^2 = \Delta x^\top G\,\Delta x$ of the _coordinate difference_, with the metric taken at the first point. Defined for **every** metric, including indefinite ones, and the causal invariant when Lorentzian. It is not the squared `geodesic_distance` except where the metric is constant along the path, i.e. on a flat manifold in Cartesian coordinates -- flatness alone is not enough, since a curvilinear chart on flat space has a varying metric. The verbs that read its sign need a timelike direction and live in the `coordinax.manifolds.lorentzian` sub-namespace below - `pt_embed`: embed intrinsic coordinates into ambient coordinates - `pt_project`: project ambient coordinates back to intrinsic chart coordinates diff --git a/packages/coordinaxs.api/src/coordinaxs/api/manifolds.py b/packages/coordinaxs.api/src/coordinaxs/api/manifolds.py index 56767bf73..6fb5fb4c5 100644 --- a/packages/coordinaxs.api/src/coordinaxs/api/manifolds.py +++ b/packages/coordinaxs.api/src/coordinaxs/api/manifolds.py @@ -11,6 +11,7 @@ "pt_map", "norm", "geodesic_distance", + "chord_distance", "interval", "causal_character", "proper_time", @@ -95,6 +96,17 @@ def geodesic_distance(*args: Any, **kwargs: Any) -> Any: raise NotImplementedError # pragma: no cover +@plum.dispatch.abstract +def chord_distance(*args: Any, **kwargs: Any) -> Any: + """Straight-line distance between two points through their ambient space. + + The chord, as opposed to `geodesic_distance`'s path along the manifold. + Defined wherever the manifold carries an embedding; a manifold that is its + own ambient space has no distinct chord and is refused. + """ + raise NotImplementedError # pragma: no cover + + @plum.dispatch.abstract def angle_between( chart: Any, uvec: Any, vvec: Any, /, *args: Any, **kwargs: Any diff --git a/src/coordinax/__init__.py b/src/coordinax/__init__.py index 48e1cb602..674c7026c 100644 --- a/src/coordinax/__init__.py +++ b/src/coordinax/__init__.py @@ -36,6 +36,7 @@ "EmbeddedManifold", "CustomAtlas", "CustomManifold", + "chord_distance", "geodesic_distance", # frames -- frames "noframe", @@ -132,6 +133,7 @@ EuclideanManifold, FlatMetric, Rn, + chord_distance, embedded_twosphere, geodesic_distance, ) diff --git a/src/coordinax/_src/manifolds/__init__.py b/src/coordinax/_src/manifolds/__init__.py index 4f1b263bb..7bfa46d9f 100644 --- a/src/coordinax/_src/manifolds/__init__.py +++ b/src/coordinax/_src/manifolds/__init__.py @@ -3,6 +3,7 @@ __all__: tuple[str, ...] = () from .angle_between import * +from .chord_distance import * from .geodesic_distance import * from .guess import * from .interval import * diff --git a/src/coordinax/_src/manifolds/chord_distance.py b/src/coordinax/_src/manifolds/chord_distance.py new file mode 100644 index 000000000..af50d5e1b --- /dev/null +++ b/src/coordinax/_src/manifolds/chord_distance.py @@ -0,0 +1,236 @@ +r"""Dispatch implementations for `coordinaxs.api.manifolds.chord_distance`. + +The straight-line distance between two points *through the ambient space* they +are embedded in -- the tunnel rather than the surface path. + +This is a different measurement from +`~coordinax.manifolds.geodesic_distance`, not an approximation to it. Both are +exact and symmetric; they answer different questions. On a sphere of radius +$R$ separated by a central angle $\theta$: + +.. math:: + + d_{\mathrm{geodesic}} = R\,\theta, + \qquad + d_{\mathrm{chord}} = 2R \sin(\theta/2). + +They agree to first order and diverge as the points separate -- 0.927 against +0.964 at $\theta = 1$ on the unit sphere, and $2R$ against $\pi R$ at +antipodes. Which one is wanted depends on the question: a great-circle flight +path is the geodesic, a line of sight through the body is the chord. + +A chord is only defined relative to an embedding, so this is implemented for +manifolds that carry one. A manifold that is its own ambient space -- anything +Euclidean -- has no distinct chord, and is directed to `geodesic_distance` +rather than being given the same number under a second name. +""" + +__all__: tuple[str, ...] = () + +from typing import Any + +import plum + +import coordinaxs.api.charts as cxcapi +import coordinaxs.api.manifolds as cxmapi +from coordinax._src.base import AbstractChart, AbstractManifold +from coordinax._src.custom_types import OptUSys +from coordinax._src.embedded.chart import EmbeddedChart +from coordinax._src.embedded.manifold import EmbeddedManifold +from coordinax._src.euclidean.manifold import EuclideanManifold +from coordinax._src.spherical.chart import sph2 +from coordinax._src.spherical.embed import TwoSphereIn3D +from coordinax._src.spherical.manifold import HyperSphericalManifold +from coordinaxs.api.custom_types import CDict + + +def _ambient_distance( + embedded: EmbeddedChart[Any, Any], + chart: AbstractChart, + intrinsic_chart: AbstractChart, + a: CDict, + b: CDict, + usys: OptUSys, + /, +) -> Any: + """Embed both points, then measure the straight line in the ambient space. + + The ambient manifold is flat, so its `geodesic_distance` *is* the straight + line -- there is no separate implementation of the chord itself. + + No fast path here, deliberately. Profiling puts ~99.5% of an eager call in + the ambient `geodesic_distance`, and ~0.5% in the embedding steps this + could skip; within the former, the cost is a single `pt_map` between charts + (~5ms eagerly, ~15us under `jit`). Hand-rolling the sphere's embedding + formula here to dodge one `pt_map` would duplicate `TwoSphereIn3D` for a + fraction of a percent. + """ + ambient_chart = embedded.ambient + + def embed(p: CDict) -> CDict: + intrinsic: CDict = cxcapi.pt_map(p, chart, intrinsic_chart, usys=usys) # ty: ignore[invalid-assignment] + out: CDict = cxmapi.pt_embed(intrinsic, embedded, usys=usys) # ty: ignore[invalid-assignment] + return out + + return cxmapi.geodesic_distance( + ambient_chart.M, ambient_chart, embed(a), embed(b), usys=usys + ) + + +@plum.dispatch +def chord_distance( + chart: AbstractChart, a: CDict, b: CDict, /, *, usys: OptUSys = None +) -> Any: + """Return the ambient straight-line distance, on the chart's manifold. + + >>> import jax.numpy as jnp + >>> import unxt as u + >>> import coordinax.charts as cxc + >>> import coordinax.manifolds as cxm + + A quarter turn along the equator of the unit sphere: the arc is ``pi / 2``, + the chord through the interior is ``sqrt(2)``. + + >>> a = {"theta": u.Angle(jnp.pi / 2, "rad"), "phi": u.Angle(0.0, "rad")} + >>> b = {"theta": u.Angle(jnp.pi / 2, "rad"), "phi": u.Angle(jnp.pi / 2, "rad")} + >>> round(float(cxm.chord_distance(cxc.sph2, a, b)), 6) + 1.414214 + >>> round(float(cxm.geodesic_distance(cxc.sph2, a, b).ustrip("rad")), 6) + 1.570796 + + Antipodes are one diameter apart through the middle, half the great-circle + distance around the outside: + + >>> n = {"theta": u.Angle(0.0, "rad"), "phi": u.Angle(0.0, "rad")} + >>> s = {"theta": u.Angle(jnp.pi, "rad"), "phi": u.Angle(0.0, "rad")} + >>> round(float(cxm.chord_distance(cxc.sph2, n, s)), 6) + 2.0 + + """ + return cxmapi.chord_distance(chart.M, chart, a, b, usys=usys) + + +@plum.dispatch +def chord_distance( + M: HyperSphericalManifold, + chart: AbstractChart, + a: CDict, + b: CDict, + /, + *, + usys: OptUSys = None, +) -> Any: + """Return the chord of the unit hypersphere, through its canonical embedding. + + >>> import jax.numpy as jnp + >>> import unxt as u + >>> import coordinax.charts as cxc + >>> import coordinax.manifolds as cxm + + Any chart on the sphere gives the same answer: + + >>> a = {"lon": u.Angle(0.0, "rad"), "lat": u.Angle(0.0, "rad")} + >>> b = {"lon": u.Angle(jnp.pi / 2, "rad"), "lat": u.Angle(0.0, "rad")} + >>> round(float(cxm.chord_distance(cxc.lonlat_sph2, a, b)), 6) + 1.414214 + + """ + if M.ndim != 2: + msg = ( + f"chord_distance is only implemented for the two-sphere; {M} is " + f"{M.ndim}-dimensional." + ) + raise NotImplementedError(msg) + unit_sphere = EmbeddedChart(TwoSphereIn3D(radius=1.0)) + return _ambient_distance(unit_sphere, chart, sph2, a, b, usys) + + +@plum.dispatch +def chord_distance( + M: EmbeddedManifold, + chart: AbstractChart, + a: CDict, + b: CDict, + /, + *, + usys: OptUSys = None, +) -> Any: + """Return the chord through the manifold's own ambient space. + + >>> import jax.numpy as jnp + >>> import unxt as u + >>> import coordinax.charts as cxc + >>> import coordinax.manifolds as cxm + + A sphere of radius 2 m: antipodes are one diameter apart. + + >>> M = cxm.EmbeddedManifold( + ... intrinsic=cxm.S2, ambient=cxm.R3, + ... embed_map=cxm.TwoSphereIn3D(radius=u.Q(2.0, "m")), + ... ) + >>> n = {"theta": u.Angle(0.0, "rad"), "phi": u.Angle(0.0, "rad")} + >>> s = {"theta": u.Angle(jnp.pi, "rad"), "phi": u.Angle(0.0, "rad")} + >>> cxm.chord_distance(M, cxc.sph2, n, s).round(6) + Distance(4., 'm') + + """ + embedded = EmbeddedChart(M.embed_map) + return _ambient_distance(embedded, chart, M.embed_map.intrinsic, a, b, usys) + + +@plum.dispatch +def chord_distance( + M: EuclideanManifold, + chart: AbstractChart, + a: CDict, + b: CDict, + /, + *, + usys: OptUSys = None, +) -> Any: + """Refuse: flat space is its own ambient, so the chord is the geodesic. + + Returning the same number under a second name invites the reader to think + two things were measured. + + >>> import unxt as u + >>> import coordinax.charts as cxc + >>> import coordinax.manifolds as cxm + + >>> a = {"x": u.Q(3.0, "m"), "y": u.Q(0.0, "m"), "z": u.Q(0.0, "m")} + >>> b = {"x": u.Q(0.0, "m"), "y": u.Q(4.0, "m"), "z": u.Q(0.0, "m")} + >>> try: cxm.chord_distance(cxc.cart3d, a, b) + ... except NotImplementedError as e: print(e) + chord_distance is a measurement through an ambient space, and Rn(3) is its + own ambient -- its chord is the straight line, which is what + `geodesic_distance` already returns. + + """ + del chart, a, b, usys + msg = ( + "chord_distance is a measurement through an ambient space, and " + f"{M} is its own ambient -- its chord is the straight line, which is " + "what `geodesic_distance` already returns." + ) + raise NotImplementedError(msg) + + +@plum.dispatch +def chord_distance( + M: AbstractManifold, + chart: AbstractChart, + a: CDict, + b: CDict, + /, + *, + usys: OptUSys = None, +) -> Any: + """Refuse: without an embedding there is no ambient space to cut through.""" + del chart, a, b, usys + msg = ( + f"no chord distance is implemented for {M}: a chord is measured " + "through an ambient space, and this manifold carries no embedding. " + "Wrap it in an `EmbeddedManifold`, or use `geodesic_distance` for the " + "distance along the manifold." + ) + raise NotImplementedError(msg) diff --git a/src/coordinax/_src/manifolds/geodesic_distance.py b/src/coordinax/_src/manifolds/geodesic_distance.py index 0f9d1f08d..7abd07cb1 100644 --- a/src/coordinax/_src/manifolds/geodesic_distance.py +++ b/src/coordinax/_src/manifolds/geodesic_distance.py @@ -40,7 +40,11 @@ AbstractMetricField, check_metric_is_charts, ) -from coordinax._src.charts.d3 import cart3d +from coordinax._src.charts.d0 import Cart0D +from coordinax._src.charts.d1 import Cart1D +from coordinax._src.charts.d2 import Cart2D +from coordinax._src.charts.d3 import Cart3D, cart3d +from coordinax._src.charts.dn import CartND from coordinax._src.custom_types import OptUSys from coordinax._src.embedded.chart import EmbeddedChart from coordinax._src.embedded.manifold import EmbeddedManifold @@ -417,3 +421,57 @@ def geodesic_distance( """ check_metric_is_charts(metric, chart, "geodesic_distance") return cxmapi.geodesic_distance(chart.M, chart, a, b, usys=usys) + + +# =================================================================== +# Fast paths. +# +# The packed overloads unpack their operands into component dicts so that a +# curvilinear chart can be mapped to Cartesian first. In a Cartesian chart +# there is nothing to map, and plum can skip the unpacking entirely by +# dispatching on the chart type: 33x for arrays, 3.3x for quantities. +# +# There is deliberately no matching `CDict` fast path. The two `pt_map` calls +# it would skip cost 4.8us of a ~3000us call -- the time is in `norm` and in +# wrapping the result as a `Distance` -- so it measured as no gain at all, and +# would have been dispatch surface bought with nothing. + +CartesianChart = Cart0D | Cart1D | Cart2D | Cart3D | CartND + + +@plum.dispatch.multi( + (CartesianChart, u.AbstractQuantity, u.AbstractQuantity), + (CartesianChart, Array, Array), +) +def geodesic_distance( + chart: CartesianChart, a: Any, b: Any, /, *, usys: OptUSys = None +) -> Any: + """Packed operands in a Cartesian chart: measure without unpacking. + + Registered as two signatures rather than one with a ``Quantity | Array`` + union: each generic overload it overrides is typed for one of them, and a + union would be narrower in the chart but wider in the operands, so plum + would rank neither more specific and refuse the call as ambiguous. + + The generic packed overloads split the operands into component dicts so a + curvilinear chart can be mapped to Cartesian. Here there is nothing to map, + and the components are already on the trailing axis in the right order, so + the norm is taken directly. + + >>> import jax.numpy as jnp + >>> import unxt as u + >>> import coordinax.charts as cxc + >>> import coordinax.manifolds as cxm + + >>> a = u.Q(jnp.asarray([3.0, 0.0, 0.0]), "m") + >>> b = u.Q(jnp.asarray([0.0, 4.0, 0.0]), "m") + >>> cxm.geodesic_distance(cxc.cart3d, a, b).round(2) + Distance(5., 'm') + + >>> float(cxm.geodesic_distance(cxc.cart3d, jnp.asarray([3.0, 0.0, 0.0]), + ... jnp.asarray([0.0, 4.0, 0.0]))) + 5.0 + + """ + del chart, usys + return _as_distance(jnp.linalg.norm(b - a, axis=-1)) diff --git a/src/coordinax/manifolds/__init__.py b/src/coordinax/manifolds/__init__.py index 4c3976fb7..87889dcd5 100644 --- a/src/coordinax/manifolds/__init__.py +++ b/src/coordinax/manifolds/__init__.py @@ -11,6 +11,7 @@ "metric_matrix", "metric_representation", "norm", + "chord_distance", "geodesic_distance", "interval", # Sub-namespaces @@ -140,6 +141,7 @@ from coordinaxs.api.charts import pt_map from coordinaxs.api.manifolds import ( angle_between, + chord_distance, geodesic_distance, guess_manifold, interval, diff --git a/tests/unit/manifolds/test_geodesic_distance_dispatch.py b/tests/unit/manifolds/test_geodesic_distance_dispatch.py index 06043e2c6..6459e0910 100644 --- a/tests/unit/manifolds/test_geodesic_distance_dispatch.py +++ b/tests/unit/manifolds/test_geodesic_distance_dispatch.py @@ -184,3 +184,71 @@ def test_refuses_a_manifold_with_no_rule(self): """The `AbstractManifold` fallback: no closed form, so no answer.""" with pytest.raises(NotImplementedError, match="no geodesic distance"): cxm.geodesic_distance(cxm.NoManifold(), cxc.sph2, _NORTH, _OTHER) + + +class TestChordDistance: + """The chord is the ambient straight line, not an approximate geodesic. + + Both are exact; they answer different questions. On a sphere of radius R + separated by a central angle t, the geodesic is ``R t`` and the chord is + ``2 R sin(t / 2)``. + """ + + @pytest.mark.parametrize("theta", [1e-3, 0.1, 1.0, 2.0, jnp.pi]) + def test_matches_the_analytic_chord(self, theta): + a = {"theta": u.Angle(jnp.pi / 2, "rad"), "phi": u.Angle(0.0, "rad")} + b = {"theta": u.Angle(jnp.pi / 2, "rad"), "phi": u.Angle(theta, "rad")} + got = jnp.asarray(cxm.chord_distance(cxc.sph2, a, b)) + assert bool(qnp.isclose(got, 2 * jnp.sin(theta / 2), atol=1e-12)) + + def test_is_symmetric_and_chart_invariant(self): + a = {"theta": u.Angle(1.0, "rad"), "phi": u.Angle(0.4, "rad")} + b = {"theta": u.Angle(1.6, "rad"), "phi": u.Angle(1.2, "rad")} + ab = jnp.asarray(cxm.chord_distance(cxc.sph2, a, b)) + ba = jnp.asarray(cxm.chord_distance(cxc.sph2, b, a)) + lonlat = jnp.asarray( + cxm.chord_distance( + cxc.lonlat_sph2, + cxc.pt_map(a, cxc.sph2, cxc.lonlat_sph2), + cxc.pt_map(b, cxc.sph2, cxc.lonlat_sph2), + ) + ) + assert bool(qnp.isclose(ab, ba, atol=1e-14)) + assert bool(qnp.isclose(ab, lonlat, atol=1e-14)) + + def test_differs_from_the_geodesic(self): + """The two must not be confusable: at a quarter turn they differ by 10%.""" + a = {"theta": u.Angle(jnp.pi / 2, "rad"), "phi": u.Angle(0.0, "rad")} + b = {"theta": u.Angle(jnp.pi / 2, "rad"), "phi": u.Angle(jnp.pi / 2, "rad")} + chord = jnp.asarray(cxm.chord_distance(cxc.sph2, a, b)) + arc = jnp.asarray(cxm.geodesic_distance(cxc.sph2, a, b).ustrip("rad")) + assert bool(qnp.isclose(chord, jnp.sqrt(2.0), atol=1e-12)) + assert bool(qnp.isclose(arc, jnp.pi / 2, atol=1e-12)) + + def test_embedded_sphere_carries_its_radius(self): + M = cxm.EmbeddedManifold( + intrinsic=cxm.S2, + ambient=cxm.R3, + embed_map=cxm.TwoSphereIn3D(radius=u.Q(2.0, "m")), + ) + north = {"theta": u.Angle(0.0, "rad"), "phi": u.Angle(0.0, "rad")} + south = {"theta": u.Angle(jnp.pi, "rad"), "phi": u.Angle(0.0, "rad")} + got = cxm.chord_distance(M, cxc.sph2, north, south) + assert bool(qnp.isclose(got.ustrip("m"), 4.0, atol=1e-12)) + + def test_flat_space_is_refused(self): + """Its own ambient: the chord is the straight line `geodesic_distance` gives.""" + a = {"x": u.Q(0.0, "m"), "y": u.Q(0.0, "m"), "z": u.Q(0.0, "m")} + b = {"x": u.Q(1.0, "m"), "y": u.Q(0.0, "m"), "z": u.Q(0.0, "m")} + with pytest.raises(NotImplementedError, match="own ambient"): + cxm.chord_distance(cxc.cart3d, a, b) + + def test_refuses_a_manifold_with_no_embedding(self): + """No ambient space to cut through, so there is no chord to return.""" + with pytest.raises(NotImplementedError, match="carries no embedding"): + cxm.chord_distance(cxm.NoManifold(), cxc.sph2, _NORTH, _OTHER) + + def test_refuses_a_sphere_that_is_not_the_two_sphere(self): + """`TwoSphereIn3D` is the only embedding wired up here.""" + with pytest.raises(NotImplementedError, match=r"only.*two-sphere"): + cxm.chord_distance(cxm.S1, cxc.sph2, _NORTH, _OTHER) From 64674a41c08774ecd029b4173e748c2c53801ec8 Mon Sep 17 00:00:00 2001 From: nstarman Date: Mon, 17 Aug 2026 10:06:47 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20fix(tests):=20strip=20units?= =?UTF-8?q?=20explicitly,=20never=20via=20`jnp.asarray`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`, which on jax 0.7.2 raises. Rebasing onto #725 narrows why that spelling was wrong. unxt 2.0.2 is the floor now, and its `Quantity.__array__` raises `UnitConversionError` for anything dimensionful rather than returning a bare array -- so `jnp.asarray` is no longer the silent stripper the first version of this commit described, and neither is `float`. The conclusion survives the correction: on the oldest supported jax it still raises for *every* `Quantity`, dimensionless included, which is the failure the `check_oldest` job reported. Same reasoning #716 records for the strips it touched. `.ustrip("")` at the five call sites, naming the unit expected rather than relying on a conversion that reads as incidental. One `jnp.asarray` wrapped around an already-stripped `ustrip("rad")` goes too -- it was the last of the pattern left in the class, and it converted nothing. The doctests keep `float(...)`, which is now load-bearing rather than lax: it raises if the unit-sphere chord ever stops being dimensionless, and the embedded case prints `Distance(4., 'm')` with the unit intact. 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 --- .../test_geodesic_distance_dispatch.py | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/unit/manifolds/test_geodesic_distance_dispatch.py b/tests/unit/manifolds/test_geodesic_distance_dispatch.py index 6459e0910..3712210bc 100644 --- a/tests/unit/manifolds/test_geodesic_distance_dispatch.py +++ b/tests/unit/manifolds/test_geodesic_distance_dispatch.py @@ -198,21 +198,19 @@ class TestChordDistance: def test_matches_the_analytic_chord(self, theta): a = {"theta": u.Angle(jnp.pi / 2, "rad"), "phi": u.Angle(0.0, "rad")} b = {"theta": u.Angle(jnp.pi / 2, "rad"), "phi": u.Angle(theta, "rad")} - got = jnp.asarray(cxm.chord_distance(cxc.sph2, a, b)) + got = cxm.chord_distance(cxc.sph2, a, b).ustrip("") assert bool(qnp.isclose(got, 2 * jnp.sin(theta / 2), atol=1e-12)) def test_is_symmetric_and_chart_invariant(self): a = {"theta": u.Angle(1.0, "rad"), "phi": u.Angle(0.4, "rad")} b = {"theta": u.Angle(1.6, "rad"), "phi": u.Angle(1.2, "rad")} - ab = jnp.asarray(cxm.chord_distance(cxc.sph2, a, b)) - ba = jnp.asarray(cxm.chord_distance(cxc.sph2, b, a)) - lonlat = jnp.asarray( - cxm.chord_distance( - cxc.lonlat_sph2, - cxc.pt_map(a, cxc.sph2, cxc.lonlat_sph2), - cxc.pt_map(b, cxc.sph2, cxc.lonlat_sph2), - ) - ) + ab = cxm.chord_distance(cxc.sph2, a, b).ustrip("") + ba = cxm.chord_distance(cxc.sph2, b, a).ustrip("") + lonlat = cxm.chord_distance( + cxc.lonlat_sph2, + cxc.pt_map(a, cxc.sph2, cxc.lonlat_sph2), + cxc.pt_map(b, cxc.sph2, cxc.lonlat_sph2), + ).ustrip("") assert bool(qnp.isclose(ab, ba, atol=1e-14)) assert bool(qnp.isclose(ab, lonlat, atol=1e-14)) @@ -220,8 +218,8 @@ def test_differs_from_the_geodesic(self): """The two must not be confusable: at a quarter turn they differ by 10%.""" a = {"theta": u.Angle(jnp.pi / 2, "rad"), "phi": u.Angle(0.0, "rad")} b = {"theta": u.Angle(jnp.pi / 2, "rad"), "phi": u.Angle(jnp.pi / 2, "rad")} - chord = jnp.asarray(cxm.chord_distance(cxc.sph2, a, b)) - arc = jnp.asarray(cxm.geodesic_distance(cxc.sph2, a, b).ustrip("rad")) + chord = cxm.chord_distance(cxc.sph2, a, b).ustrip("") + arc = cxm.geodesic_distance(cxc.sph2, a, b).ustrip("rad") assert bool(qnp.isclose(chord, jnp.sqrt(2.0), atol=1e-12)) assert bool(qnp.isclose(arc, jnp.pi / 2, atol=1e-12))