Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
87e5ed5
fix: __version__ guard catches everything and leaves it unset on failure
petercorke Aug 17, 2026
8c9f35f
test: add regression coverage for trlog near-identity division
petercorke Aug 20, 2026
457128f
refactor(base): collapse the three-way Python-version type shim into …
petercorke Aug 20, 2026
8f1ab30
perf(base): speed up isR/ishom/ishom2 orthogonality checks
petercorke Aug 21, 2026
af473db
perf(base): speed up trnorm and tr2adjoint
petercorke Aug 21, 2026
9f13356
perf(base): speed up qqmul and qvmul
petercorke Aug 21, 2026
764e7f5
perf(base): speed up isskewa and cache identity matrix in trexp/trlog…
petercorke Aug 21, 2026
e6ce47f
chore: switch docs build to PyPI sphinx-pyrunblock instead of git+https
petercorke Sep 15, 2026
890d078
docs: add sphinx-copybutton and sphinx-codeautolink to docs deps
petercorke Sep 15, 2026
0c8b16a
docs: register sphinx_copybutton/sphinx_codeautolink extensions
petercorke Sep 15, 2026
21ba463
docs: configure sphinx-codeautolink and sphinx-copybutton options
petercorke Sep 15, 2026
563de34
docs: fix :linenos: indentation mismatch in intro.rst
petercorke Sep 16, 2026
e5abf12
fix: broken docstring examples in Twist2/Twist3/DualQuaternion, and t…
petercorke Sep 16, 2026
283f4ad
docs: note conj()'s sign fix as a versionchanged, bug present in 1.1.…
petercorke Sep 16, 2026
bde3690
chore: declare Python 3.13 and 3.14 support, add 3.14 to CI matrix
petercorke Sep 18, 2026
dbaaaf1
chore: reorganize timing.py into per-category tables, collapse test b…
petercorke Aug 21, 2026
5f5a893
chore: move timing.py to benchmarks/benchmark_smtb.py
petercorke Sep 18, 2026
a3f828f
feat(benchmarks): print machine and version summary before results
petercorke Sep 18, 2026
2c46775
fix: trplot/trplot2 silently swallowed unknown keyword arguments
petercorke Sep 26, 2026
3deefc8
Make Line3 relation tests invariant to Plucker scale
Doribelove Sep 27, 2026
149653a
test: avoid round-off in scaled Line3 fixture
Doribelove Sep 28, 2026
b91dcd8
Fix double transform of animation start pose in Animate.trplot
RoboX2020 Oct 9, 2026
c08331f
Add regression test for animation start pose
RoboX2020 Oct 9, 2026
c8d7188
Reject unsupported ndarray ranks in isvector
MokiMeow Oct 9, 2026
56c5f21
test: add direct tests for e2h/h2e; fix docstring seealso
petercorke Oct 10, 2026
e7682ea
fix: h2e rejects input with fewer than 2 rows; state e2h/h2e contract
petercorke Oct 10, 2026
f829872
Strengthen animation start-pose test against static trplot geometry
RoboX2020 Oct 10, 2026
73222bf
Accept a stacked (N, r, c) array as a sequence of poses
RoboX2020 Oct 10, 2026
1057abf
SE3: accept a stacked (N, 4, 4) array and say what is accepted in the…
RoboX2020 Oct 10, 2026
3f06d5e
Test SE3 and SO3 construction from a stacked array
RoboX2020 Oct 10, 2026
08cb279
Defer scipy imports in spline.py
RoboX2020 Oct 11, 2026
a105dbb
Defer scipy.interpolate import in quaternions
RoboX2020 Oct 11, 2026
e5802a5
Add test that importing spatialmath does not load scipy
RoboX2020 Oct 11, 2026
d8bb1ad
Merge PR #206: fix: __version__ guard catches everything and leaves i…
Copilot Oct 11, 2026
7dc65e3
Merge PR #208: test: add regression coverage for trlog near-identity …
Copilot Oct 11, 2026
d805d37
Merge PR #212: refactor(base): collapse the three-way Python-version …
Copilot Oct 11, 2026
31df473
Merge PR #213: perf(base): speed up isR/ishom/ishom2 orthogonality ch…
Copilot Oct 11, 2026
e5ec7fa
Merge PR #214: perf(base): speed up trnorm and tr2adjoint
Copilot Oct 11, 2026
ea2f29d
Merge PR #215: perf(base): speed up qqmul and qvmul
Copilot Oct 11, 2026
d8287e9
Merge PR #216: perf(base): speed up isskewa, cache identity in trexp/…
Copilot Oct 11, 2026
c6f3195
Merge PR #221: chore: switch docs build to PyPI sphinx-pyrunblock ins…
Copilot Oct 11, 2026
ea4d487
Merge PR #223: docs: add sphinx-copybutton and sphinx-codeautolink
Copilot Oct 11, 2026
3a47f8e
Merge PR #225: fix: broken docstring examples in Twist2/Twist3/DualQu…
Copilot Oct 11, 2026
67338f2
Merge PR #228: chore: declare Python 3.13/3.14 support, add 3.14 to CI
Copilot Oct 11, 2026
590e3d4
Merge PR #229: chore: reorganize timing script as benchmarks/benchmar…
Copilot Oct 11, 2026
bc04e03
Merge PR #230: fix: trplot/trplot2 silently swallowed unknown keyword…
Copilot Oct 11, 2026
14bd3e1
Merge PR #232: Make Line3 relation tests invariant to Plücker scale
Copilot Oct 11, 2026
1ccc728
Merge PR #238: Fix animation start pose being applied twice in Animat…
Copilot Oct 11, 2026
e9ef1af
Merge PR #239: Reject unsupported ndarray ranks in isvector
Copilot Oct 11, 2026
347769a
Merge PR #240: test+fix: direct tests for e2h/h2e, h2e rejects <2 row…
Copilot Oct 11, 2026
961e94b
Merge PR #243: Accept a stacked (N, r, c) ndarray as a sequence of po…
Copilot Oct 11, 2026
6a7cd4b
Merge PR #244: Defer scipy imports so import spatialmath is faster
Copilot Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
matrix:
os: [windows-latest, ubuntu-22.04, macos-latest]
# https://devguide.python.org/versions/
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
exclude:
- os: windows-latest
python-version: "3.11"
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/sphinx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install .[dev,docs]
pip install git+https://github.com/petercorke/sphinx-autorun.git
pip install sympy
sudo apt-get install graphviz
- name: Build docs
Expand Down
292 changes: 292 additions & 0 deletions benchmarks/benchmark_smtb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Micro-benchmarks for spatialmath: base functions and classes for SO(3),
SE(3), quaternions and twists, plus a NumPy baseline. Run from a checkout::

python benchmarks/benchmark_smtb.py

The output starts with a summary of the machine and package versions, so a
pasted table is self-describing.

Created on Fri Apr 10 14:22:36 2020

@author: Peter Corke
"""

import os
import platform
import subprocess
import timeit as _timeit

import numpy as np
from ansitable import ANSITable, Column

import spatialmath

N = 10_000
REPEATS = 5

table = None


def cpu_info() -> str:
"""Best-effort, portable one-line CPU description

:return: CPU name and core count, plus clock speed if available
:rtype: str

No new hard dependency: psutil is used for clock speed only if already
installed. Some platforms (e.g. Apple Silicon) don't expose a single
meaningful clock speed, so a missing or nonsensical reading is omitted.
"""
system = platform.system()
name = None

if system == "Darwin":
try:
name = subprocess.check_output(
["sysctl", "-n", "machdep.cpu.brand_string"], text=True
).strip()
except Exception:
pass
elif system == "Linux":
try:
with open("/proc/cpuinfo") as f:
for line in f:
if line.lower().startswith("model name"):
name = line.split(":", 1)[1].strip()
break
except Exception:
pass
elif system == "Windows":
name = platform.processor() or None

if not name:
name = platform.processor() or platform.machine() or "unknown CPU"

info = f"{name} ({os.cpu_count() or '?'} cores)"

try:
import psutil

freq = psutil.cpu_freq()
# real clock speeds are hundreds to thousands of MHz; some platforms
# report bogus single-digit values instead of raising
if freq and freq.max and freq.max > 100:
info += f", {freq.max:.0f} MHz"
except Exception:
pass

return info


def print_machine_summary() -> None:
print(f"CPU: {cpu_info()}")
print(f"OS: {platform.platform()}")
print(f"Python: {platform.python_version()}")
print(f"numpy: {np.__version__}")
print(f"spatialmath: {spatialmath.__version__}")
print(f"Timing: min of {REPEATS} repeats x {N} calls")


def new_table():
return ANSITable(
Column("Operation", headalign="^"),
Column("Time (μs)", headalign="^", fmt="{:.2f}"),
border="thick",
)


def timeit(stmt, label, setup):
# min-of-repeats suppresses system jitter (GC pauses, OS scheduling)
# much better than a single long run of the same total work
t = min(_timeit.repeat(stmt=stmt, setup=setup, number=N, repeat=REPEATS))
table.row(label, t / N * 1e6)


def section(title):
# print the previous table (if any), then start a fresh one under a
# plain header naming the category of test that follows
global table
if table is not None:
table.print()
print(f"\n{title}\n")
table = new_table()


print_machine_summary()

# ------------------------------------------------------------------------- #
transforms_setup = '''
from spatialmath import SE3
from spatialmath import base

import numpy as np
from collections import namedtuple
Rt = namedtuple('Rt', 'R t')
X1 = SE3.Rand()
X2 = SE3.Rand()
T1 = X1.A
T2 = X2.A
R1 = base.t2r(T1)
R2 = base.t2r(T2)
t1 = base.transl(T1)
t2 = base.transl(T2)
Rt1 = Rt(R1, t1)
Rt2 = Rt(R2, t2)
v = np.r_[1,2,3]
v2 = np.r_[1,2,3, 1]
'''

section("SO(3) / SE(3) base functions")

timeit('base.getvector(0.2)', "base.getvector(x)", transforms_setup)
timeit('base.rotx(0.2, unit="rad")', "base.rotx(x)", transforms_setup)
timeit('base.trotx(0.2, unit="rad")', "base.trotx(x)", transforms_setup)
timeit('base.t2r(T1)', "base.t2r(T1)", transforms_setup)
timeit('base.r2t(R1)', "base.r2t(R1)", transforms_setup)
timeit('T1 @ T2', "T1 @ T2 (4x4)", transforms_setup)
timeit(
'T1[:3,:3] @ T2[:3,:3] + T1[:3,:3] @ T2[:3,3]',
"T1 @ T2 decomposed (slice)",
transforms_setup,
)
timeit(
'(Rt1.R @ Rt2.R, Rt1.R @ Rt2.t)',
"T1 @ T2 decomposed (namedtuple)",
transforms_setup,
)
timeit('base.trinv(T1)', "base.trinv(T1)", transforms_setup)
timeit(
'(Rt1.R.T, -Rt1.R.T @ Rt1.t)', "T1 inverse decomposed (R,t)", transforms_setup
)
timeit('np.linalg.inv(T1)', "np.linalg.inv(T1)", transforms_setup)
timeit('T1 @ v2', "T1 @ v2 (4,4)*(4,)", transforms_setup)

# ------------------------------------------------------------------------- #
section("SE3 class")

timeit('SE3()', "SE3()", transforms_setup)
timeit('SE3.Rx(0.2)', "SE3.Rx(x)", transforms_setup)
timeit('SE3(T1)', "SE3(T1)", transforms_setup)
timeit('SE3(T1, check=False)', "SE3(T1, check=False)", transforms_setup)
timeit('SE3([T1], check=False)', "SE3([T1], check=False)", transforms_setup)
timeit('T1[:3,:3]', "T1[:3,:3] (raw slice)", transforms_setup)
timeit('X1.A', "X1.A (property)", transforms_setup)
timeit('X1 * X2', "X1 * X2", transforms_setup)
timeit('X1 @ X2', "X1 @ X2 (normalized)", transforms_setup)
timeit('X1.inv()', "X1.inv()", transforms_setup)
timeit('X1 * v', "X1 * v", transforms_setup)
timeit('a = X1.log()', "X1.log()", transforms_setup)
timeit('SE3.RPY([0.1, 0.2, 0.3])', "SE3.RPY(rpy)", transforms_setup)
timeit('X1.rpy()', "X1.rpy()", transforms_setup)
timeit('SE3.Eul([0.1, 0.2, 0.3])', "SE3.Eul(eul)", transforms_setup)
timeit('X1.eul()', "X1.eul()", transforms_setup)
timeit('X1.UnitQuaternion()', "X1.UnitQuaternion()", transforms_setup)

# ------------------------------------------------------------------------- #
quat_setup = '''
from spatialmath import base
from spatialmath import UnitQuaternion, SO3
import numpy as np
q1 = base.qrand()
q2 = base.qrand()
v = np.r_[1,2,3]
R1 = SO3.Rand().R
Q1 = UnitQuaternion.Rx(0.2)
Q2 = UnitQuaternion.Ry(0.3)
'''

section("Quaternion base functions")

timeit('a = base.qqmul(q1,q2)', "base.qqmul(q1, q2)", quat_setup)
timeit('a = base.qvmul(q1,v)', "base.qvmul(q1, v)", quat_setup)

# ------------------------------------------------------------------------- #
section("UnitQuaternion class")

timeit('a = UnitQuaternion()', "UnitQuaternion()", quat_setup)
timeit('a = UnitQuaternion.Rx(0.2)', "UnitQuaternion.Rx(x)", quat_setup)
timeit('a = UnitQuaternion(R1)', "UnitQuaternion(R1)", quat_setup)
timeit('a = Q1.R', "Q1.R (property)", quat_setup)
timeit('a = Q1 * Q2', "Q1 * Q2", quat_setup)
timeit('a = Q1 * v', "Q1 * v", quat_setup)
timeit('a = Q1.SE3()', "Q1.SE3()", quat_setup)

# ------------------------------------------------------------------------- #
twist_setup = '''
from spatialmath import SE3, Twist3
from spatialmath import base
import numpy as np
from math import cos
S1 = Twist3(SE3.Rand())
S2 = Twist3(SE3.Rand())
X1 = SE3.Rand()
T1 = X1.A
A1 = X1.Ad()
se3 = S1.skewa()
s = np.r_[1,2,3,4,5,6]
v = np.r_[1,2,3]
'''

section("Twist / exponential-map base functions")

timeit('a = base.skew(v)', "base.skew(v)", twist_setup)
timeit('a = base.skewa(s)', "base.skewa(s)", twist_setup)
timeit('a = base.vexa(se3)', "base.vexa(se3)", twist_setup)
timeit('a = base.trlog(T1)', "base.trlog(T1)", twist_setup)
timeit('a = base.trlog(T1, twist=True)', "base.trlog(T1, twist=True)", twist_setup)
timeit('a = base.trexp(se3)', "base.trexp(se3)", twist_setup)
timeit('a = base.rodrigues(v)', "base.rodrigues(v)", twist_setup)
timeit('a = A1 @ s', "A1 @ s (6,6)*(6,)", twist_setup)
timeit('a = cos(0.3)', "math.cos(x)", twist_setup)
timeit('a = np.cos(0.3)', "np.cos(x)", twist_setup)

# ------------------------------------------------------------------------- #
section("Twist3 class")

# NB: Twist3 * Twist3 and Twist3.Ad() both round-trip through the SE3
# exponential/logarithm maps (trexp/trlog above), which is why they cost
# several times more than a single trexp or trlog call - see
# claude-notes/twist3-timing-investigation.md for the profiling detail.

timeit('a = Twist3()', "Twist3()", twist_setup)
timeit('a = Twist3(X1)', "Twist3(X1) (via log)", twist_setup)
timeit('a = S1.inv()', "S1.inv()", twist_setup)
timeit('a = S1 * S2', "S1 * S2 (product of exponentials)", twist_setup)
timeit('a = S1.Ad()', "S1.Ad() (via SE3 + tr2adjoint)", twist_setup)
timeit('a = S1.exp(1)', "S1.exp() -> SE3", twist_setup)

# ------------------------------------------------------------------------- #
misc_setup = """
from spatialmath import base
import numpy as np
s = np.r_[1.0,2,3,4,5,6]
s3 = np.r_[1.0,2,3]
a = np.r_[1.0, 2.0, 3.0]
b = np.r_[-5.0, 4.0, 3.0]

A = np.random.randn(6,6)
As = (A + A.T) / 2
bb = np.random.randn(6)
"""

section("NumPy linear-algebra baseline")

timeit("c = np.linalg.inv(As)", "np.linalg.inv(As)", misc_setup)
timeit("c = np.linalg.pinv(As)", "np.linalg.pinv(As)", misc_setup)
timeit("c = np.linalg.solve(As, bb)", "np.linalg.solve(As, b)", misc_setup)
timeit("c = np.cross(a,b)", "np.cross(a, b)", misc_setup)
timeit("c = base.cross(a,b)", "base.cross(a, b)", misc_setup)
timeit("a = np.inner(s,s).sum()", "np.inner(s, s).sum()", misc_setup)
timeit("a = np.linalg.norm(s) ** 2", "np.linalg.norm(s) ** 2", misc_setup)
timeit("a = base.normsq(s)", "base.normsq(s)", misc_setup)
timeit("a = (s ** 2).sum()", "(s ** 2).sum()", misc_setup)
timeit("a = np.sum(s ** 2)", "np.sum(s ** 2)", misc_setup)
timeit("a = np.linalg.norm(s)", "np.linalg.norm(s) [R6]", misc_setup)
timeit("a = base.norm(s)", "base.norm(s) [R6]", misc_setup)
timeit("a = np.linalg.norm(s3)", "np.linalg.norm(s3) [R3]", misc_setup)
timeit("a = base.norm(s3)", "base.norm(s3) [R3]", misc_setup)

table.print()
29 changes: 28 additions & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# sys.path.insert(0, os.path.abspath('.'))
# sys.path.insert(0, os.path.abspath('..'))

from sphinx_codeautolink import clean_ipython, clean_pycon

# -- Project information -----------------------------------------------------

Expand Down Expand Up @@ -47,9 +48,17 @@
"sphinx.ext.inheritance_diagram",
"matplotlib.sphinxext.plot_directive",
"sphinx_autodoc_typehints",
"sphinx_autorun",
"sphinx_pyrunblock",
"sphinx.ext.intersphinx",
"sphinx_favicon",
"sphinx_copybutton",
"sphinx_codeautolink",
]

suppress_warnings = [
"codeautolink.match_block",
"codeautolink.match_name",
"config.cache", # codeautolink_custom_blocks holds function refs, not picklable
]
#'sphinx.ext.autosummary',
# typehints_use_signature_return = True
Expand Down Expand Up @@ -184,6 +193,24 @@
np.set_printoptions(precision=4, suppress=True)
"""

# -------- sphinx-codeautolink options --------------------------------------------#

codeautolink_custom_blocks = {
"pycon": clean_pycon,
"ipython": clean_ipython,
"ipython3": clean_ipython,
}
# Ensure pycon (Python console) blocks are included in the autolink search.
codeautolink_search_css_classes = ["highlight-python", "highlight-pycon"]

# -------- sphinx-copybutton options ----------------------------------------------#
# Strip interactive prompts (Python and shell) when users copy code snippets.

copybutton_prompt_text = r">>> |\.\.\. |\$ "
copybutton_prompt_is_regexp = True
copybutton_only_copy_prompt_lines = False
copybutton_remove_prompts = True

intersphinx_mapping = {
"numpy": ("http://docs.scipy.org/doc/numpy/", None),
"scipy": ("http://docs.scipy.org/doc/scipy/reference/", None),
Expand Down
4 changes: 2 additions & 2 deletions docs/source/intro.rst
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,7 @@ Graphics
If ``matplotlib`` is installed then we can add 2D coordinate frames to a figure in a variety of styles:

.. code-block:: python
:linenos:
:linenos:
>>> trplot2( transl2(1,2), frame='A', rviz=True, width=1)
>>> trplot2( transl2(3,1), color='red', arrow=True, width=3, frame='B')
Expand All @@ -646,7 +646,7 @@ If a figure does not yet exist one is added. If a figure exists but there is no
Similarly, we can plot 3D coordinate frames in a variety of styles:

.. code-block:: python
:linenos:
:linenos:
>>> trplot( transl(1,2,3), frame='A', rviz=True, width=1, dims=[0, 10, 0, 10, 0, 10])
>>> trplot( transl(3,1, 2), color='red', width=3, frame='B')
Expand Down
Loading
Loading