Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
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
dfe0bb7
fix: preserve rotation quadrants in trlog2
vera-rubin Oct 14, 2026
5fb7852
Defer optional SymPy loading until symbolic use
RoboX2020 Oct 15, 2026
2690a37
Check symbolic scalar types dynamically
RoboX2020 Oct 15, 2026
81c4ee4
Defer SymPy loading in transforms2d.py
RoboX2020 Oct 15, 2026
82b2dd6
Defer SymPy loading in transformsNd.py
RoboX2020 Oct 15, 2026
5df29aa
Defer SymPy loading in vectors.py
RoboX2020 Oct 15, 2026
514a615
Test optional SymPy loading in fresh interpreters
RoboX2020 Oct 15, 2026
69c8289
Merge branch 'rai-opensource:master' into perf/lazy-sympy
RoboX2020 Oct 15, 2026
1fb1b5d
Using check=False seems to return empty types
taughz Oct 16, 2026
fb39007
fix(graphics): axes_logic uses an empty current figure instead of cre…
petercorke Oct 17, 2026
87ac1ea
fix(graphics): preserve unspecified dimensions in expand_dims
Doribelove Oct 21, 2026
5c4a3d1
fix(geom3d): return a point from intersecting lines
Doribelove Oct 22, 2026
9c9dd30
Fix pi() docstring indentation and stray import in runblock
RoboX2020 Oct 26, 2026
67ebc01
Keep check=False lenient; reject only wrong-size stacked arrays
RoboX2020 Oct 26, 2026
d86d08c
Merge upstream master and retain both expand_dims test suites
Doribelove Oct 26, 2026
fa014b4
Solve the plane equations for a three-plane intersection
xixihaha-ha Nov 3, 2026
fa95c27
Fix the constant coefficient sign in Plane3.contains
xixihaha-ha Nov 3, 2026
7be61bd
Include line moment when constructing a plane from a line and point
xixihaha-ha Nov 3, 2026
babe57a
deprecate: UnitQuaternion.interp1's shortest parameter is a no-op
petercorke Aug 20, 2026
208fe9b
fix: Twist3 * SpatialVector raised ValueError instead of dispatching
petercorke Aug 20, 2026
a660079
Defer scipy imports in spline.py
RoboX2020 Oct 11, 2026
fab7d5d
Defer scipy.interpolate import in quaternions
RoboX2020 Oct 11, 2026
cf3c701
Add test that importing spatialmath does not load scipy
RoboX2020 Oct 11, 2026
7782731
perf: compute Twist3.Ad() directly instead of via a throwaway SE3
petercorke Aug 20, 2026
bcc0d6b
fix: twist sequence predicates, scalar left-multiply, and copied docs…
petercorke Oct 15, 2026
af4ea00
docs: describe the displacement and velocity usages of Twist2/Twist3
petercorke Oct 15, 2026
b02516c
feat(base): add environment() and move isnotebook out of the matplotl…
petercorke Oct 22, 2026
60c634c
chore: bump version to 1.1.19
Copilot Nov 5, 2026
4ab65ff
Move three-plane tests to the end of the test file
Copilot Nov 5, 2026
8eb0e3b
build: drop oldest-supported-numpy from build requirements
taughz Nov 5, 2026
a6117fd
Merge PR #210: fix: Twist3 * SpatialVector raised ValueError instead …
taughz Nov 5, 2026
8de58d8
Merge PR #256: fix: preserve rotation quadrants in trlog2
taughz Nov 5, 2026
ece3742
Merge PR #258: fix: twist sequence predicates, scalar left-multiply, …
taughz Nov 5, 2026
b57d384
Merge PR #270: fix(graphics): accept unspecified dimensions in expand…
taughz Nov 5, 2026
13978c8
Merge PR #209: deprecate: UnitQuaternion.interp1's shortest parameter…
taughz Nov 5, 2026
03f3491
Merge PR #211: perf: compute Twist3.Ad() directly instead of via a th…
taughz Nov 5, 2026
3a8b49b
Merge PR #243: Accept a stacked (N, r, c) ndarray as a sequence of po…
taughz Nov 5, 2026
f0c17a3
Merge PR #244: Defer scipy imports so import spatialmath is faster
taughz Nov 5, 2026
5ef54b5
Merge PR #264: Defer optional SymPy imports until symbolic operations…
taughz Nov 5, 2026
0b06951
Merge PR #265: fix(graphics): axes_logic uses an empty current figure…
taughz Nov 5, 2026
68e086b
Merge PR #271: feat(base): add environment() and move isnotebook out …
taughz Nov 5, 2026
6f641a0
Merge PR #273: fix(geom3d): return the intersection point of two lines
taughz Nov 5, 2026
7763356
Merge PR #279: Fix intersection of three planes
taughz Nov 5, 2026
408fece
Merge PR #280: Fix the constant coefficient sign in Plane3.contains
taughz Nov 5, 2026
ae1902f
Merge PR #281: Include line moment when constructing a plane from a l…
taughz Nov 5, 2026
39c420f
Merge PR #286: build: drop oldest-supported-numpy from build requirem…
taughz Nov 5, 2026
bcbec7b
Merge PR #284: chore: bump version to 1.1.19
taughz Nov 5, 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
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "spatialmath-python"
version = "1.1.18"
version = "1.1.19"
authors = [
{ name="Peter Corke", email="rvc@petercorke.com" },
]
Expand Down Expand Up @@ -78,7 +78,7 @@ ros-humble = [

[build-system]

requires = ["setuptools", "oldest-supported-numpy"]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[tool.setuptools]
Expand Down
3 changes: 2 additions & 1 deletion spatialmath/base/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from spatialmath.base.vectors import * # lgtm [py/polluting-import]
from spatialmath.base.symbolic import * # lgtm [py/polluting-import]
from spatialmath.base.numeric import * # lgtm [py/polluting-import]
from spatialmath.base._environment import * # lgtm [py/polluting-import]

import importlib

Expand Down Expand Up @@ -46,7 +47,6 @@
"plotvol2": "graphics",
"plotvol3": "graphics",
"expand_dims": "graphics",
"isnotebook": "graphics",
}


Expand Down Expand Up @@ -394,6 +394,7 @@ def __getattr__(name):
"axes_logic",
"set_window_title",
"expand_dims",
"environment",
"isnotebook",
# spatial.base.numeric
"numjac",
Expand Down
78 changes: 78 additions & 0 deletions spatialmath/base/_environment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Part of Spatial Math Toolbox for Python
# Copyright (c) 2000 Peter Corke
# MIT Licence, see details in top-level file: LICENCE

"""
Detect the interactive environment that Python is running in.

This module deliberately does not import Matplotlib, so it is cheap to import
and works when Matplotlib is not installed.
"""

import sys
from typing import Literal

__all__ = ["environment", "isnotebook"]

Environment = Literal["terminal", "ipython", "jupyter", "colab", "jupyterlite"]


def environment() -> Environment:
"""
Determine the environment that Python is running in

:return: the environment
:rtype: str

The result is one of:

======================== ====================================================
``"terminal"`` standard Python interpreter, or script
``"ipython"`` IPython running in a terminal
``"jupyter"`` Jupyter kernel, eg. JupyterLab, Notebook, VS Code or
qtconsole
``"colab"`` Google Colab
``"jupyterlite"`` JupyterLite, running in the browser (Pyodide)
======================== ====================================================

.. note:: The different Jupyter front ends (JupyterLab, classic Notebook,
VS Code, Jupyter Desktop) cannot be reliably told apart from within the
kernel, so all are reported as ``"jupyter"``. How figures will be
rendered depends on the Matplotlib backend, not on the front end.

:seealso: :func:`isnotebook`
"""
# Colab and JupyterLite both also provide an IPython kernel, so test for
# them first
if "google.colab" in sys.modules:
return "colab"
if sys.platform == "emscripten":
return "jupyterlite"

try:
shell = get_ipython().__class__.__name__ # type: ignore[name-defined]
except NameError:
return "terminal" # probably standard Python interpreter

if shell == "ZMQInteractiveShell":
return "jupyter" # Jupyter notebook or qtconsole
elif shell == "TerminalInteractiveShell":
return "ipython"
else:
return "terminal" # other type (?)


def isnotebook() -> bool:
"""
Determine if code is being run from a Jupyter notebook

:return: True if running in a Jupyter kernel, Google Colab or JupyterLite
:rtype: bool

:seealso: :func:`environment`

:references:

- https://stackoverflow.com/questions/15411967/how-can-i-check-if-code-is-executed-in-the-ipython-notebook/39662359#39662359
"""
return environment() in ("jupyter", "colab", "jupyterlite")
20 changes: 9 additions & 11 deletions spatialmath/base/argcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
from collections.abc import Iterable

# from spatialmath.base import symbolic as sym # HACK
from spatialmath.base.symbolic import issymbol, symtype
from spatialmath.base.symbolic import issymbol

# valid scalar types
_scalartypes = (int, np.integer, float, np.floating) + symtype
_scalartypes = (int, np.integer, float, np.floating)

# from typing import Union, List, Tuple, Any, Optional, Type, Callable
# from numpy.typing import DTypeLike
Expand Down Expand Up @@ -47,7 +47,9 @@ def isscalar(x: Any) -> bool:
>>> isscalar([1])

"""
return isinstance(x, _scalartypes)
return isinstance(x, _scalartypes) or (
not isinstance(x, (list, tuple)) and issymbol(x)
)


def isinteger(x: Any) -> bool:
Expand Down Expand Up @@ -386,7 +388,7 @@ def getvector(
"""
dt = dtype

if isinstance(v, _scalartypes): # handle scalar case
if isscalar(v): # handle scalar case
v = [v] # type: ignore
if isinstance(v, (list, tuple)):
# list or tuple was passed in
Expand Down Expand Up @@ -501,7 +503,7 @@ def isvector(v: Any, dim: Optional[int] = None) -> bool:
if (
isinstance(v, (list, tuple))
and (dim is None or len(v) == dim)
and all(map(lambda x: isinstance(x, _scalartypes), v))
and all(map(isscalar, v))
):
return True # list or tuple

Expand All @@ -516,7 +518,7 @@ def isvector(v: Any, dim: Optional[int] = None) -> bool:
else:
return s == (dim,) or s == (1, dim) or s == (dim, 1)

if (dim is None or dim == 1) and isinstance(v, _scalartypes):
if (dim is None or dim == 1) and isscalar(v):
return True

return False
Expand Down Expand Up @@ -620,11 +622,7 @@ def isnumberlist(x: Any) -> bool:
>>> isnumberlist(np.r_[1,2])
"""

return (
isinstance(x, (list, tuple))
and len(x) > 0
and all(map(lambda x: isinstance(x, _scalartypes), x))
)
return isinstance(x, (list, tuple)) and len(x) > 0 and all(map(isscalar, x))


def isvectorlist(x: Any, n: int) -> bool:
Expand Down
54 changes: 25 additions & 29 deletions spatialmath/base/graphics.py
Original file line number Diff line number Diff line change
Expand Up @@ -1513,7 +1513,10 @@ def _axes_dimensions(ax: plt.Axes) -> int:
# handle the case of Animate objects pretending to be Axes
classname = ax.__class__.__name__
base_classes = ax.__class__.__bases__
if classname in ("Axes3DSubplot", "Animate") or any(base_class.__name__ in ("Axes3DSubplot", "Animate") for base_class in base_classes):
if classname in ("Axes3DSubplot", "Animate") or any(
base_class.__name__ in ("Axes3DSubplot", "Animate")
for base_class in base_classes
):
return 3
elif classname in ("AxesSubplot", "Animate2"):
return 2
Expand Down Expand Up @@ -1580,6 +1583,7 @@ def axes_logic(
"""

# print(f"new axis logic ({dimensions}D): ", end='')
use_current_figure = False
if ax is None:
# no axes passed in, find out what's happening
# need to be careful to not use gcf() or gca() since they
Expand All @@ -1597,6 +1601,10 @@ def axes_logic(
# print(f"ax has {_axes_dimensions(ax)} dimensions")
if _axes_dimensions(ax) == dimensions and not new:
return ax
elif not new:
# the current figure is empty, eg. from a bare plt.figure():
# put the axes in it rather than leave it empty and make another
use_current_figure = True
# otherwise it doesnt exist or dimension mismatch, create new axes
else:
# print("ax given", ax)
Expand All @@ -1607,7 +1615,8 @@ def axes_logic(
return ax
# print("mismatch in dimensions, create new axes")
# print("create new axes")
plt.figure()
if not use_current_figure:
plt.figure()
if dimensions == 2:
ax = plt.axes()
if autoscale:
Expand Down Expand Up @@ -1736,20 +1745,24 @@ def plotvol3(
ax._plotvol = True
return ax

def expand_dims(dim: ArrayLike = None, nd: int = 2) -> NDArray:
def expand_dims(dim: ArrayLike | None = None, nd: int = 2) -> NDArray | None:
"""
Expand compact axis dimensions

:param dim: dimensions, defaults to None
:param dim: dimensions, or None if unspecified, defaults to None
:type dim: scalar, array_like(2), array_like(4), array_like(6), optional
:param nd: number of axes dimensions, defaults to 2
:param nd: number of axes dimensions (2 or 3), defaults to 2
:type nd: int, optional
:raises ValueError: bad arguments
:return: 2d or 3d dimensions vector
:rtype: ndarray(4) or ndarray(6)
:return: 2d or 3d dimensions vector, or None if unspecified
:rtype: ndarray(4), ndarray(6), or None

Compute bounding dimensions for plots from shorthand notation.

``None`` is returned unchanged so the caller can choose whether to
autoscale or obtain dimensions from another source. No default extent
is inferred.

If ``nd==2``, [xmin, xmax, ymin, ymax]:
* A -> [-A, A, -A, A]
* [A,B] -> [A, B, A, B]
Expand All @@ -1766,6 +1779,11 @@ def expand_dims(dim: ArrayLike = None, nd: int = 2) -> NDArray:
corresponding maximum, matplotlib will invert that axis when the limits
are applied, which is a convenient way to flip an axis direction.
"""
if nd not in (2, 3):
raise ValueError("nd is 2 or 3")
if dim is None:
return None

dim = smb.getvector(dim)

if nd == 2:
Expand All @@ -1788,8 +1806,6 @@ def expand_dims(dim: ArrayLike = None, nd: int = 2) -> NDArray:
return dim
else:
raise ValueError("bad dimension specified")
else:
raise ValueError("nd is 2 or 3")

def set_window_title(fig: plt.Figure, title: str) -> bool:
"""
Expand Down Expand Up @@ -1822,26 +1838,6 @@ def set_window_title(fig: plt.Figure, title: str) -> bool:
return True
return False

def isnotebook() -> bool:
"""
Determine if code is being run from a Jupyter notebook

:references:

- https://stackoverflow.com/questions/15411967/how-can-i-check-if-code-
is-executed-in-the-ipython-notebook/39662359#39662359
"""
try:
shell = get_ipython().__class__.__name__
if shell == "ZMQInteractiveShell":
return True # Jupyter notebook or qtconsole
elif shell == "TerminalInteractiveShell":
return False # Terminal running IPython
else:
return False # Other type (?)
except NameError:
return False # Probably standard Python interpreter

if __name__ == "__main__":
import pathlib

Expand Down
10 changes: 7 additions & 3 deletions spatialmath/base/quaternions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
import spatialmath.base as smb
from spatialmath.base.argcheck import getunit
from spatialmath.base.types import *
import scipy.interpolate as interpolate
from typing import Optional
from typing import TYPE_CHECKING, Optional
from functools import lru_cache
import warnings

if TYPE_CHECKING:
import scipy.interpolate as interpolate

_eps = np.finfo(np.float64).eps


Expand Down Expand Up @@ -920,7 +922,7 @@ def _compute_cdf_sin_squared(theta: float):
@lru_cache(maxsize=1)
def _generate_inv_cdf_sin_squared_interp(
num_interpolation_points: int = 256,
) -> interpolate.interp1d:
) -> "interpolate.interp1d":
"""
Computes an interpolation function for the inverse CDF of the distribution of angular magnitude.

Expand All @@ -933,6 +935,8 @@ def _generate_inv_cdf_sin_squared_interp(
This function returns interpolation function for the inverse of the integral of the
pdf of angular magnitudes (2/pi * sin^2(theta/2)), which is not analytically defined.
"""
import scipy.interpolate as interpolate # deferred, it is slow to import

cdf_sin_squared_interp_angles = np.linspace(0, np.pi, num_interpolation_points)
cdf_sin_squared_interp_values = _compute_cdf_sin_squared(
cdf_sin_squared_interp_angles
Expand Down
Loading
Loading