Skip to content

Commit 633fae3

Browse files
nstarmanclaude
andcommitted
✨ feat(transforms): kinematic acts for Scale/Reflect/Shear without a base point
Only Boost and Rotate had TangentGeometry (velocity/acceleration) act paths; the other linear transforms fell through to the generic jet prolongation, which requires a base point `at` even for a constant Cartesian map where none is needed (`act(Scale, None, velocity, cart3d, coord_vel)` raised TypeError). A linear map's Jacobian is its (constant) matrix, so a Cartesian velocity or acceleration transforms as `v -> M v` with no anchor. Register a shared `pushforward` on `AbstractLinearTransform`: Cartesian charts apply `M` directly (no `at`); non-Cartesian charts push through the chart Jacobian (which does need `at`); time-dependent linear maps still route through the generic prolongation for the `dot(M)` term. Rotate keeps its own (more specific) dispatch, so there is no ambiguity. A clear TypeError now reports tangent/anchor components that do not match the chart. Verified: fast path equals the generic prolongation when `at` is supplied (the suite's keystone property); Scale/Reflect/Shear velocity + acceleration; non-Cartesian still requires `at`. New unit tests + doctest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b6aa5dd commit 633fae3

3 files changed

Lines changed: 183 additions & 3 deletions

File tree

src/coordinax/transforms/_src/actions/linear.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,118 @@ def _maybe(
231231
for i, (f, p) in enumerate(zip(chart.factors, parts, strict=True))
232232
)
233233
return chart.merge_components(mapped_parts)
234+
235+
236+
# ============================================================================
237+
# pushforward — tangent geometry (shared by every linear transform)
238+
239+
240+
def _linear_pushforward_cdict(
241+
op: AbstractLinearTransform,
242+
tau: Any,
243+
x: CDict,
244+
chart: cxc.AbstractChart,
245+
rep: cxr.Representation,
246+
/,
247+
*,
248+
at: CDict | None = None,
249+
usys: OptUSys = None,
250+
) -> CDict:
251+
r"""Frozen-$\tau$ pushforward of tangent data under a linear map: $v \mapsto M v$.
252+
253+
A linear map has a *constant* Jacobian equal to its matrix ``M``, so in the
254+
canonical Cartesian chart the pushforward is simply ``M v`` and needs no
255+
base point ``at``. For a non-Cartesian chart the tangent is pushed through
256+
the chart Jacobian (which does require ``at``), ``M`` is applied in
257+
Cartesian, then the result is pulled back to the original chart.
258+
259+
Examples
260+
--------
261+
Scale a Cartesian velocity vector (no base point needed):
262+
263+
>>> import quaxed.numpy as jnp
264+
>>> import unxt as u
265+
>>> import coordinax.charts as cxc
266+
>>> import coordinax.representations as cxr
267+
>>> import coordinax.transforms as cxfm
268+
269+
>>> op = cxfm.Scale.from_factors([2.0, 3.0, 1.0])
270+
>>> v = {"x": u.Q(1.0, "m/s"), "y": u.Q(1.0, "m/s"), "z": u.Q(1.0, "m/s")}
271+
>>> out = cxfm.act(op, None, v, cxc.cart3d, cxr.tangent_geom, cxr.coord_vel)
272+
>>> jnp.stack([out[c].to_value("m/s") for c in ("x", "y", "z")]).round(3)
273+
Array([2., 3., 1.], dtype=float64)
274+
275+
"""
276+
# The tangent must carry exactly the chart's components (a clear error
277+
# instead of a raw KeyError when packing); an anchor, if given, must match.
278+
expected = set(chart.components)
279+
for name, d in (("tangent", x), *(() if at is None else (("base point", at),))):
280+
if set(d) != expected:
281+
missing = sorted(expected - set(d))
282+
extra = sorted(set(d) - expected)
283+
msg = (
284+
f"pushforward({type(op).__name__}, ...): the {name} components "
285+
f"do not match the chart's {sorted(expected)}"
286+
+ (f"; missing {missing}" if missing else "")
287+
+ (f"; unexpected {extra}" if extra else "")
288+
+ "."
289+
)
290+
raise TypeError(msg)
291+
292+
cart = chart.cartesian
293+
matrix = op._matrix(cart, tau)
294+
295+
if chart is cart:
296+
p_cart = x
297+
else:
298+
if at is None:
299+
msg = (
300+
f"pushforward({type(op).__name__}, ..., TangentGeometry) on a "
301+
f"non-Cartesian chart ({chart!r}) requires 'at' (base point in "
302+
"chart coords) so the Jacobian pushforward can be evaluated."
303+
)
304+
raise TypeError(msg)
305+
at_cart = cxc.pt_map(at, chart, cart, usys=usys)
306+
p_cart = cxr.tangent_map(x, chart, rep, cart, at=at, usys=usys) # ty: ignore[missing-argument]
307+
308+
comps_cart = cart.components
309+
v, unit = pack_uniform_unit(p_cart, keys=comps_cart) # ty: ignore[no-matching-overload]
310+
v_out = jnp.einsum("ij,...j->...i", matrix, v)
311+
p_cart_out = cxc.cdict(v_out, unit, comps_cart)
312+
313+
if chart is cart:
314+
return cast("CDict", p_cart_out)
315+
316+
# Map the base point forward (M @ at) to anchor the inverse Jacobian.
317+
at_arr, at_unit = pack_uniform_unit(at_cart, keys=comps_cart) # ty: ignore[no-matching-overload]
318+
at_out_arr = jnp.einsum("ij,...j->...i", matrix, at_arr)
319+
at_out = cxc.cdict(at_out_arr, at_unit, comps_cart)
320+
return cast(
321+
"CDict",
322+
cxr.tangent_map(p_cart_out, cart, rep, chart, at=at_out, usys=usys), # ty: ignore[missing-argument]
323+
)
324+
325+
326+
@plum.dispatch
327+
def pushforward(
328+
op: AbstractLinearTransform,
329+
tau: Any,
330+
v: CDict,
331+
chart: cxc.AbstractChart,
332+
rep: cxr.Representation,
333+
/,
334+
*,
335+
at: CDict | None = None,
336+
usys: OptUSys = None,
337+
) -> CDict:
338+
r"""Frozen-$\tau$ pushforward of tangent data under a linear map.
339+
340+
A linear map's Jacobian is its (constant) matrix, so a Cartesian velocity
341+
or acceleration transforms as ``v -> M v`` with no base point required —
342+
matching the behavior already provided for `Rotate`. This is used by the
343+
generic tangent ``act`` for displacement data and for every time-independent
344+
linear transform (`Scale`, `Reflect`, `Shear`). Time-dependent linear maps
345+
still route through the generic prolongation, which supplies the ``dot(M)``
346+
term and requires the jet anchors.
347+
"""
348+
return _linear_pushforward_cdict(op, tau, v, chart, rep, at=at, usys=usys)

tests/unit/transforms/test_prolongation.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -491,11 +491,24 @@ def test_prolong_additive_skips_intermediate_slots(self):
491491
out = cxfm.prolong(moving, u.Q(1.0, "s"), jet, cxc.cart3d)
492492
assert jnp.allclose(u.ustrip("km/s2", out[2]["x"]), 2.0)
493493

494-
def test_static_scale_vel_requires_at(self):
494+
def test_static_scale_vel_no_at_needed(self):
495+
# A static (time-independent) linear map has a constant Jacobian equal
496+
# to its matrix, so a Cartesian velocity transforms as v -> M v with no
497+
# base point required (matching Rotate).
495498
op = cxfm.Scale.from_factors([2.0, 3.0, 4.0])
496499
v = q3(1.0, 1.0, 1.0, "m/s")
497-
with pytest.raises(TypeError, match="base point"):
498-
cxfm.act(op, None, v, cxc.cart3d, cxr.coord_vel)
500+
out = cxfm.act(op, None, v, cxc.cart3d, cxr.coord_vel)
501+
assert jnp.allclose(u.ustrip("m/s", out["x"]), 2.0)
502+
assert jnp.allclose(u.ustrip("m/s", out["y"]), 3.0)
503+
assert jnp.allclose(u.ustrip("m/s", out["z"]), 4.0)
504+
505+
def test_static_scale_vel_noncartesian_still_requires_at(self):
506+
# On a non-Cartesian chart the Jacobian varies with position, so the
507+
# base point is still required.
508+
op = cxfm.Scale.from_factors([2.0, 3.0, 4.0])
509+
v = {"r": u.Q(1.0, "m/s"), "theta": u.Q(0.0, "rad/s"), "phi": u.Q(0.0, "rad/s")}
510+
with pytest.raises(TypeError, match="'at'"):
511+
cxfm.act(op, None, v, cxc.sph3d, cxr.coord_vel)
499512

500513

501514
# ============================================================================

tests/unit/transforms/test_spatial_linear_transforms.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import quaxed.numpy as jnp
1111
import unxt as u
1212

13+
import coordinax.charts as cxc
14+
import coordinax.representations as cxr
1315
import coordinax.transforms as cxfm
1416

1517

@@ -73,3 +75,53 @@ def test_simplify_identity_scale_and_shear_to_identity() -> None:
7375

7476
assert cxfm.simplify(s) is cxfm.identity
7577
assert cxfm.simplify(h) is cxfm.identity
78+
79+
80+
# ============================================================================
81+
# Kinematic (velocity / acceleration) acts — constant linear maps need no `at`
82+
83+
84+
def _vel(x, y, z):
85+
return {"x": u.Q(x, "m/s"), "y": u.Q(y, "m/s"), "z": u.Q(z, "m/s")}
86+
87+
88+
@pytest.mark.parametrize(
89+
("op", "expected"),
90+
[
91+
(cxfm.Scale.from_factors([2.0, 3.0, 4.0]), (2.0, 3.0, 4.0)),
92+
(cxfm.Reflect.from_normal([1.0, 0.0, 0.0]), (-1.0, 1.0, 1.0)),
93+
(
94+
cxfm.Shear(
95+
jnp.asarray([[1.0, 1.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])
96+
),
97+
(2.0, 1.0, 1.0),
98+
),
99+
],
100+
)
101+
def test_linear_transform_acts_on_velocity_without_at(op, expected) -> None:
102+
"""A constant linear map transforms a Cartesian velocity as v -> M v, no `at`."""
103+
out = cxfm.act(op, None, _vel(1.0, 1.0, 1.0), cxc.cart3d, cxr.coord_vel)
104+
got = tuple(round(float(u.ustrip("m/s", out[c])), 6) for c in ("x", "y", "z"))
105+
assert got == expected
106+
107+
108+
def test_linear_transform_acts_on_acceleration_without_at() -> None:
109+
"""Acceleration also transforms as a -> M a for a constant linear map."""
110+
op = cxfm.Scale.from_factors([2.0, 3.0, 4.0])
111+
acc = {"x": u.Q(1.0, "m/s2"), "y": u.Q(0.0, "m/s2"), "z": u.Q(2.0, "m/s2")}
112+
out = cxfm.act(op, None, acc, cxc.cart3d, cxr.coord_acc)
113+
got = tuple(round(float(u.ustrip("m/s2", out[c])), 6) for c in ("x", "y", "z"))
114+
assert got == (2.0, 0.0, 8.0)
115+
116+
117+
def test_linear_velocity_matches_generic_prolongation_with_at() -> None:
118+
"""Keystone: the no-`at` fast path equals the generic prolongation given `at`."""
119+
op = cxfm.Scale.from_factors([2.0, 3.0, 4.0])
120+
v = _vel(1.0, -2.0, 0.5)
121+
at = {"x": u.Q(2.0, "m"), "y": u.Q(-1.0, "m"), "z": u.Q(3.0, "m")}
122+
fast = cxfm.act(op, None, v, cxc.cart3d, cxr.coord_vel)
123+
withat = cxfm.act(op, None, v, cxc.cart3d, cxr.coord_vel, at=at)
124+
for c in ("x", "y", "z"):
125+
np.testing.assert_allclose(
126+
_to_np(fast[c], "m/s"), _to_np(withat[c], "m/s"), atol=1e-12
127+
)

0 commit comments

Comments
 (0)