Skip to content

feat(pose,quaternion): add __imatmul__ (@=) operator - #199

Open
petercorke wants to merge 2 commits into
rai-opensource:masterfrom
petercorke:feat/imatmul
Open

feat(pose,quaternion): add __imatmul__ (@=) operator#199
petercorke wants to merge 2 commits into
rai-opensource:masterfrom
petercorke:feat/imatmul

Conversation

@petercorke

Copy link
Copy Markdown
Collaborator

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.

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@petercorke

Copy link
Copy Markdown
Collaborator Author

Merge-order suggestion across the currently open PRs, based on a full file-overlap check across all of them. Nothing here is a conflict with anything already merged — these are parallel PRs sharing some source files with each other, so a rebase will likely be needed wherever paths cross, regardless of order.

Recommended order:

  1. Fixes to base.plot_box() #179 (fix/base-plot-box) — already approved, CI green, ready now
  2. Fix/twist pitch clean #196 (fix/twist-pitch-clean) — isolated to twist.py
  3. test: force headless Matplotlib backend for local + CI test runs #197 (fix/headless-tests-locally) — isolated to test files only
  4. docs: fix Sphinx docstring formatting and reST syntax errors #195 (docs/sphinx-docstring-fixes) — touches ~18 files, overlaps most of the PRs below; docstring-only/low-risk, so merging it here means the functional PRs below each only rebase against it once
  5. feat(pose,quaternion): add __imatmul__ (@=) operator #199 (feat/imatmul) — only conflicts with refactor: consistent printline() across pose and quaternion classes #200 (this one)
  6. fix(base): export tr2pos2, pos2tr2, tr2adjoint2; rename tradjoint2 #194 (fix/export-base-functions) — conflicts with perf: defer Matplotlib import until something actually plots #198 and refactor: consistent printline() across pose and quaternion classes #200
  7. perf: defer Matplotlib import until something actually plots #198 (perf/lazy-matplotlib-import) — conflicts with fix(base): export tr2pos2, pos2tr2, tr2adjoint2; rename tradjoint2 #194 and refactor: consistent printline() across pose and quaternion classes #200
  8. refactor: consistent printline() across pose and quaternion classes #200 (refactor/printline) — touches the most shared surface (conflicts with fix(base): export tr2pos2, pos2tr2, tr2adjoint2; rename tradjoint2 #194, perf: defer Matplotlib import until something actually plots #198, feat(pose,quaternion): add __imatmul__ (@=) operator #199); merging it last means it absorbs one final rebase instead of three others rebasing against it

`X @= Y` now compounds poses/quaternions in place with automatic
normalization, mirroring the existing `X @ Y` (__matmul__) behaviour
but writing the normalized result back into the left operand instead
of returning a new one. Useful when a pose is updated incrementally
over many cycles and you don't want to pay for a fresh object each
time.

Added to BasePoseMatrix (covers SO2/SE2/SO3/SE3) and Quaternion
(covers Quaternion/UnitQuaternion).
Quaternion.__imatmul__'s own 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 a separate @=
operator. UnitQuaternion.__matmul__ (pre-existing, unchanged) already
does this correctly via smb.qunit(smb.qqmul(x, y)) - qunit being the
normalizer; qnorm just returns the scalar magnitude, so it was never
actually 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): plain
Quaternion has no __matmul__ (only UnitQuaternion defines one, with
normalization), and calling the dunder directly as a plain attribute
bypasses Python's normal operator fallback, raising a confusing
AttributeError instead of the same TypeError `q1 @ q2` already raises
for plain Quaternion. `left @ right` matches @'s behaviour exactly in
both cases: normalizes for UnitQuaternion, raises consistently for
Quaternion. Also fixed the docstring's `-> bool` return type (should
be `-> Quaternion`) and its example, which used Quaternion.Eul() - a
method that only exists on UnitQuaternion.

Tests: added @= coverage for UnitQuaternion (must match @, not *) and
for plain Quaternion (must raise TypeError, matching @, not silently
degrade to *=).
@taughz
taughz self-requested a review as a code owner September 3, 2026 17:53
@taughz
taughz requested a lite review from Copilot and removed request for myeatman-bdai September 3, 2026 17:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new __imatmul__ docstrings and pose @= tests need small adjustments to accurately document raised exceptions/normalization semantics and to unambiguously assert @= matches @.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds support for the augmented matrix-multiply operator (@=) for pose matrices and quaternions, aligning augmented assignment behavior with existing @ composition semantics (including normalization where applicable) and extending test coverage to exercise the new operator.

Changes:

  • Add __imatmul__ to BasePoseMatrix to support SO2/SE2/SO3/SE3 @= via normalized pose composition.
  • Add __imatmul__ to Quaternion to support UnitQuaternion @= (normalized) and ensure plain Quaternion @= fails consistently like @.
  • Extend unit tests to cover @= for UnitQuaternion, SO3, and SE3.
File summaries
File Description
spatialmath/quaternion.py Adds Quaternion.__imatmul__ delegating to @ and updates related doc text nearby.
spatialmath/baseposematrix.py Adds BasePoseMatrix.__imatmul__ delegating to __matmul__ for normalized composition.
tests/test_quaternion.py Adds assertions that @= matches @ for UnitQuaternion and raises TypeError for plain Quaternion.
tests/test_pose3d.py Adds basic @= coverage for SO3 and SE3.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.


:return: Product of two operands with normalization
:rtype: Pose instance or NumPy array
:raises ValueError: for incompatible arguments
Comment thread spatialmath/quaternion.py
Comment on lines +679 to +684
:raises: ValueError

``q1 @= q2`` sets ``q1 := qnorm(q1 * q2)``. Only meaningful for
``UnitQuaternion``, which is the only subclass defining ``__matmul__``
(with normalization) that this delegates to; on a plain ``Quaternion``
this raises the same ``TypeError`` that ``q1 @ q2`` would.
Comment thread tests/test_pose3d.py
Comment on lines +422 to +426
R = SO3()
R @= SO3.Rx(pi / 2)
self.assertIsInstance(R, SO3)
array_compare(R, rotx(pi / 2))

Comment thread tests/test_pose3d.py
Comment on lines +1086 to +1092
T = SE3(1, 2, 3)
T @= SE3.Ry(pi / 2)
self.assertIsInstance(T, SE3)
array_compare(
T, np.array([[0, 0, 1, 1], [0, 1, 0, 2], [-1, 0, 0, 3], [0, 0, 0, 1]])
)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants