Skip to content

Commit 45ed832

Browse files
authored
feat(pose,quaternion): add __imatmul__ (@=) operator (#199)
## What `X @= Y` now works as the augmented-assignment form of the existing `X @ Y` (`__matmul__`, which composes with normalization) — previously only `X *= Y` existed, which composes *without* normalization. Useful when a pose or unit quaternion is updated incrementally over many cycles and you want the normalized form without writing `X = X @ Y` by hand. Added to `BasePoseMatrix` (covers `SO2`/`SE2`/`SO3`/`SE3`) and `Quaternion` (covers `Quaternion`/`UnitQuaternion`). Note on semantics: like the existing `__imul__`, this doesn't mutate the object in place — `__imatmul__` returns a new (normalized) object and Python rebinds the name, same as `X = X @ Y`. That matches the existing `*=` pattern in this codebase exactly, just saves writing `X = X @ Y`. ## A bug found while adding test coverage This started as a cherry-pick of old, never-merged WIP work. While writing tests I found `Quaternion.__imatmul__`'s docstring claimed `q1 @= q2` sets `q1 := qnorm(q1 * q2)`, but the implementation just delegated to `__mul__` — identical to plain `*=`, no normalization at all, contradicting both the docstring and the entire point of adding `@=`. `UnitQuaternion.__matmul__` (pre-existing, unchanged) already normalizes correctly via `smb.qunit(smb.qqmul(x, y))` — `qunit` being the actual normalizer; `qnorm` just returns the scalar magnitude, so it was never really the right function despite the docstring's wording. Fixed by having `__imatmul__` delegate to `left @ right` instead of `left.__mul__(right)`. Deliberately not `left.__matmul__(right)` either: plain `Quaternion` has no `__matmul__` (only `UnitQuaternion` defines one), and calling the dunder directly as a plain attribute bypasses Python's normal operator fallback, raising a confusing `AttributeError` instead of the `TypeError` that `q1 @ q2` already raises consistently for plain `Quaternion`. `left @ right` matches `@`'s behaviour exactly in both cases. Also fixed the docstring's `-> bool` return type annotation (should be `-> Quaternion`) and its example, which called `Quaternion.Eul()` — a method that only exists on `UnitQuaternion`. ## Testing - Added `@=` coverage for `SO3`/`SE3` (must match `@`) alongside the existing `*=` tests in `test_pose3d.py`. - Added `@=` coverage for `UnitQuaternion` (must match `@`, not `*`) and for plain `Quaternion` (must raise `TypeError`, matching `@`, not silently degrade to `*=`) in `test_quaternion.py`. - Full suite: 338 passed, 4 skipped. - `black --check` clean at the pinned 23.10.0.
1 parent b7e7bee commit 45ed832

4 files changed

Lines changed: 73 additions & 2 deletions

File tree

spatialmath/baseposematrix.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,6 +1360,20 @@ def __imul__(left, right): # noqa
13601360
"""
13611361
return left.__mul__(right)
13621362

1363+
def __imatmul__(left, right): # noqa
1364+
"""
1365+
Overloaded ``@=`` operator (superclass method)
1366+
1367+
:return: Product of two operands with normalization
1368+
:rtype: Pose instance or NumPy array
1369+
:raises ValueError: for incompatible arguments
1370+
1371+
- ``X @= Y`` compounds the poses ``X`` and ``Y`` and places the normalized result in ``X``
1372+
1373+
:seealso: ``__imul__`` :meth:`__matmul__`
1374+
"""
1375+
return left.__matmul__(right)
1376+
13631377
def __truediv__(left, right): # pylint: disable=no-self-argument
13641378
"""
13651379
Overloaded ``/`` operator (superclass method)

spatialmath/quaternion.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,40 @@ def __imul__(
669669
"""
670670
return left.__mul__(right)
671671

672+
def __imatmul__(
673+
left, right: Quaternion
674+
) -> Quaternion: # lgtm[py/not-named-self] pylint: disable=no-self-argument
675+
"""
676+
Overloaded ``@=`` operator
677+
678+
:return: product
679+
:rtype: Quaternion
680+
:raises: ValueError
681+
682+
``q1 @= q2`` sets ``q1 := qnorm(q1 * q2)``. Only meaningful for
683+
``UnitQuaternion``, which is the only subclass defining ``__matmul__``
684+
(with normalization) that this delegates to; on a plain ``Quaternion``
685+
this raises the same ``TypeError`` that ``q1 @ q2`` would.
686+
687+
Example:
688+
689+
.. runblock:: pycon
690+
691+
>>> from spatialmath import UnitQuaternion
692+
>>> q = UnitQuaternion.Eul([0.1, 0.2, 0.3])
693+
>>> q @= UnitQuaternion.Eul([0.3, 0.4, 0.5])
694+
>>> print(q)
695+
696+
697+
:seealso: :func:`__matmul__`
698+
"""
699+
# NOT left.__matmul__(right): Quaternion itself has no __matmul__
700+
# (only UnitQuaternion defines one), and calling the dunder
701+
# directly as a plain attribute skips Python's normal operator
702+
# fallback, raising a confusing AttributeError instead of the
703+
# TypeError that `left @ right` raises consistently.
704+
return left @ right
705+
672706
def __pow__(self, n: int) -> Quaternion:
673707
"""
674708
Overloaded ``**`` operator
@@ -1887,8 +1921,6 @@ def __matmul__(
18871921
- ``q1 @ q2`` is the Hamilton product of ``q1`` and ``q2``, both unit
18881922
quaternions, followed by explicit normalization.
18891923
1890-
- `` q1 @= q2`` as above.
1891-
18921924
.. note:: This operator is functionally equivalent to ``*`` but is more
18931925
costly. It is useful for cases where a pose is incrementally update
18941926
over many cycles.

tests/test_pose3d.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,11 @@ def test_arith(self):
419419
self.assertIsInstance(R, SO3)
420420
array_compare(R, rotx(pi / 2))
421421

422+
R = SO3()
423+
R @= SO3.Rx(pi / 2)
424+
self.assertIsInstance(R, SO3)
425+
array_compare(R, rotx(pi / 2))
426+
422427
R = SO3()
423428
R *= 2
424429
self.assertNotIsInstance(R, SO3)
@@ -1078,6 +1083,13 @@ def test_arith(self):
10781083
T, np.array([[0, 0, 1, 1], [0, 1, 0, 2], [-1, 0, 0, 3], [0, 0, 0, 1]])
10791084
)
10801085

1086+
T = SE3(1, 2, 3)
1087+
T @= SE3.Ry(pi / 2)
1088+
self.assertIsInstance(T, SE3)
1089+
array_compare(
1090+
T, np.array([[0, 0, 1, 1], [0, 1, 0, 2], [-1, 0, 0, 3], [0, 0, 0, 1]])
1091+
)
1092+
10811093
T = SE3()
10821094
T *= 2
10831095
self.assertNotIsInstance(T, SE3)

tests/test_quaternion.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,11 @@ def test_matmul(self):
508508
UnitQuaternion([ry * rx, rz * ry, rx * rz]),
509509
)
510510

511+
# @= is @ as an augmented assignment, not *=
512+
q = rx
513+
q @= ry
514+
qcompare(q, rx @ ry)
515+
511516
# def multiply_test_normalized(self):
512517

513518
# vx = [1, 0, 0]; vy = [0, 1, 0]; vz = [0, 0, 1]
@@ -990,6 +995,14 @@ def test_multiply(self):
990995
q *= q2
991996
qcompare(q, [-12, 6, 24, 12])
992997

998+
# plain Quaternion has no @ (only UnitQuaternion normalizes via @),
999+
# so @= must fail the same way @ does, not silently fall back to *=
1000+
with self.assertRaises(TypeError):
1001+
q1 @ q2
1002+
with self.assertRaises(TypeError):
1003+
q = q1
1004+
q @= q2
1005+
9931006
# vector x vector
9941007
qcompare(
9951008
Quaternion([q1, u, q2, u, q3, u]) * Quaternion([u, q1, u, q2, u, q3]),

0 commit comments

Comments
 (0)