From 3d21b065178cc7543e834c42d0bcf9a3936e1ee9 Mon Sep 17 00:00:00 2001 From: Sebastian Castro <169091242+scastro-bdai@users.noreply.github.com> Date: Mon, 30 Dec 2024 11:49:31 -0500 Subject: [PATCH 01/14] Fix string equality warnings in `SplineFit` class (#147) --- spatialmath/spline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spatialmath/spline.py b/spatialmath/spline.py index 0a472ecc..8d30bd80 100644 --- a/spatialmath/spline.py +++ b/spatialmath/spline.py @@ -192,14 +192,14 @@ def stochastic_downsample_interpolation( ) sample_time = self.time_data[candidate_removal_index] - if check_type is "local": + if check_type == "local": angular_error = SO3(self.pose_data[candidate_removal_index]).angdist( SO3(self.spline.spline_so3(sample_time).as_matrix()) ) euclidean_error = np.linalg.norm( self.pose_data[candidate_removal_index].t - self.spline.spline_xyz(sample_time) ) - elif check_type is "global": + elif check_type == "global": angular_error = self.max_angular_error() euclidean_error = self.max_euclidean_error() else: From 7fe17c9ade4b9620b5e0564094a58472c28d5530 Mon Sep 17 00:00:00 2001 From: Ben Axelrod Date: Wed, 8 Jan 2025 10:14:52 -0500 Subject: [PATCH 02/14] [SW-1712] Pin ubuntu version in workflows (#148) --- .github/workflows/master.yml | 4 ++-- .github/workflows/publish.yml | 2 +- .github/workflows/sphinx.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 19e2f3fb..23deebe9 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -16,7 +16,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [windows-latest, ubuntu-latest, macos-latest] + os: [windows-latest, ubuntu-22.04, macos-latest] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] exclude: - os: windows-latest @@ -44,7 +44,7 @@ jobs: # If all tests pass: # Run coverage and upload to codecov needs: unittest - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v2 - name: Set up Python 3.8 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fc49c0ed..e2d439fd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: strategy: max-parallel: 2 matrix: - os: [ubuntu-latest] + os: [ubuntu-22.04] python-version: [3.8] steps: diff --git a/.github/workflows/sphinx.yml b/.github/workflows/sphinx.yml index 7ce7d443..9b24d2b3 100644 --- a/.github/workflows/sphinx.yml +++ b/.github/workflows/sphinx.yml @@ -5,7 +5,7 @@ on: jobs: sphinx: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 if: ${{ github.event_name != 'pull_request' }} steps: - uses: actions/checkout@v2 From 0ff5c8318d1e1c34c69f3a13db8ed8639348a492 Mon Sep 17 00:00:00 2001 From: jbarnett-bdai Date: Mon, 13 Jan 2025 10:30:08 -0500 Subject: [PATCH 03/14] Optional deps humble (#150) --- README.md | 7 +++++++ pyproject.toml | 9 +++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3efadf10..a2db78e4 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,13 @@ Install a snapshot from PyPI pip install spatialmath-python ``` +Note that if you are using ROS2, you may run into version conflicts when using `rosdep`, particularly +concerning `matplotlib`. If this happens, you can enable optional version pinning with + +``` +pip install spatialmath-python[ros-humble] +``` + ## From GitHub Install the current code base from GitHub and pip install a link to that cloned copy diff --git a/pyproject.toml b/pyproject.toml index cead2ac3..4295f2f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,9 +36,9 @@ keywords = [ ] dependencies = [ - "numpy>=1.22,<2", # Cannot use 2.0 due to matplotlib version pinning. + "numpy>=1.22", "scipy", - "matplotlib==3.5.1", # Large user-base has apt-installed python3-matplotlib (ROS2) which is pinned to this version. + "matplotlib", "ansitable", "typing_extensions", "pre-commit", @@ -70,6 +70,11 @@ docs = [ "sphinx-autodoc-typehints", ] +ros-humble = [ + "matplotlib==3.5.1", # Large user-base has apt-installed python3-matplotlib (ROS2) which is pinned to this version. + "numpy<2", # Cannot use 2.0 due to matplotlib version pinning. +] + [build-system] requires = ["setuptools", "oldest-supported-numpy"] From 12128c595223493297ef2a0dbd55232891b04493 Mon Sep 17 00:00:00 2001 From: Mark Yeatman <129521731+myeatman-bdai@users.noreply.github.com> Date: Wed, 22 Jan 2025 08:02:20 -0500 Subject: [PATCH 04/14] Temporarily remove pre commit config. (#153) --- .pre-commit-config.yaml | 35 ----------------------------------- 1 file changed, 35 deletions(-) delete mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 7c28f075..00000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,35 +0,0 @@ -repos: -- repo: https://github.com/charliermarsh/ruff-pre-commit - # Ruff version. - rev: 'v0.1.0' - hooks: - - id: ruff - args: ['--fix', '--config', 'pyproject.toml'] - -- repo: https://github.com/psf/black - rev: 23.10.0 - hooks: - - id: black - language_version: python3.10 - args: ['--config', 'pyproject.toml'] - verbose: true - -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 - hooks: - - id: end-of-file-fixer - - id: debug-statements # Ensure we don't commit `import pdb; pdb.set_trace()` - exclude: | - (?x)^( - docker/ros/web/static/.*| - )$ - - id: trailing-whitespace - exclude: | - (?x)^( - docker/ros/web/static/.*| - (.*/).*\.patch| - )$ -# - repo: https://github.com/pre-commit/mirrors-mypy -# rev: v1.6.1 -# hooks: -# - id: mypy From 23066289d4e32b1b03ac99529fc44bd8a2427c2b Mon Sep 17 00:00:00 2001 From: Jien Cao <135634522+jcao-bdai@users.noreply.github.com> Date: Wed, 22 Jan 2025 08:12:11 -0500 Subject: [PATCH 05/14] Bug fixes [Issue-136], [Issue-137] (#138) Co-authored-by: Mark Yeatman <129521731+myeatman-bdai@users.noreply.github.com> --- .github/workflows/sphinx.yml | 4 ++-- spatialmath/base/graphics.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/sphinx.yml b/.github/workflows/sphinx.yml index 9b24d2b3..45105ee5 100644 --- a/.github/workflows/sphinx.yml +++ b/.github/workflows/sphinx.yml @@ -9,10 +9,10 @@ jobs: if: ${{ github.event_name != 'pull_request' }} steps: - uses: actions/checkout@v2 - - name: Set up Python 3.7 + - name: Set up Python 3.8 uses: actions/setup-python@v5 with: - python-version: 3.7 + python-version: 3.8 - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/spatialmath/base/graphics.py b/spatialmath/base/graphics.py index 2ce18dc8..c51d7f94 100644 --- a/spatialmath/base/graphics.py +++ b/spatialmath/base/graphics.py @@ -982,7 +982,7 @@ def plot_sphere( handles = [] for c in centre.T: X, Y, Z = sphere(centre=c, radius=radius, resolution=resolution) - handles.append(_render3D(ax, X, Y, Z, **kwargs)) + handles.append(_render3D(ax, X, Y, Z, pose=pose, **kwargs)) return handles @@ -1214,7 +1214,7 @@ def plot_cylinder( ) # Pythagorean theorem handles = [] - handles.append(_render3D(ax, X, Y, Z, filled=filled, **kwargs)) + handles.append(_render3D(ax, X, Y, Z, filled=filled, pose=pose, **kwargs)) handles.append( _render3D(ax, X, (2 * centre[1] - Y), Z, filled=filled, pose=pose, **kwargs) ) @@ -1298,9 +1298,9 @@ def plot_cone( Z = height - Z handles = [] - handles.append(_render3D(ax, X, Y, Z, filled=filled, **kwargs)) + handles.append(_render3D(ax, X, Y, Z, pose=pose, filled=filled, **kwargs)) handles.append( - _render3D(ax, X, (2 * centre[1] - Y), Z, filled=filled, **kwargs) + _render3D(ax, X, (2 * centre[1] - Y), Z, pose=pose, filled=filled, **kwargs) ) if ends and kwargs.get("filled", default=False): From 59873f14c92e0c490de7bef523e10f5438d38263 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Sun, 26 Jan 2025 17:00:08 +1100 Subject: [PATCH 06/14] compute mean of a set of rotations (#160) --- spatialmath/pose3d.py | 43 ++++++++++++---- spatialmath/quaternion.py | 78 +++++++++++++++++++++++------ tests/test_pose3d.py | 31 ++++++++++++ tests/test_quaternion.py | 100 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 223 insertions(+), 29 deletions(-) diff --git a/spatialmath/pose3d.py b/spatialmath/pose3d.py index b4301d93..4e558512 100644 --- a/spatialmath/pose3d.py +++ b/spatialmath/pose3d.py @@ -843,22 +843,22 @@ def Exp( def UnitQuaternion(self) -> UnitQuaternion: """ - SO3 as a unit quaternion instance + SO3 as a unit quaternion instance - :return: a unit quaternion representation - :rtype: UnitQuaternion instance + :return: a unit quaternion representation + :rtype: UnitQuaternion instance - ``R.UnitQuaternion()`` is an ``UnitQuaternion`` instance representing the same rotation - as the SO3 rotation ``R``. + ``R.UnitQuaternion()`` is an ``UnitQuaternion`` instance representing the same rotation + as the SO3 rotation ``R``. - Example: + Example: - .. runblock:: pycon + .. runblock:: pycon - >>> from spatialmath import SO3 - >>> SO3.Rz(0.3).UnitQuaternion() + >>> from spatialmath import SO3 + >>> SO3.Rz(0.3).UnitQuaternion() - """ + """ # Function level import to avoid circular dependencies from spatialmath import UnitQuaternion @@ -931,6 +931,29 @@ def angdist(self, other: SO3, metric: int = 6) -> Union[float, ndarray]: else: return ad + def mean(self, tol: float = 20) -> SO3: + """Mean of a set of rotations + + :param tol: iteration tolerance in units of eps, defaults to 20 + :type tol: float, optional + :return: the mean rotation + :rtype: :class:`SO3` instance. + + Computes the Karcher mean of the set of rotations within the SO(3) instance. + + :references: + - `**Hartley, Trumpf** - "Rotation Averaging" - IJCV 2011 `_, Algorithm 1, page 15. + - `Karcher mean `_ + """ + + eta = tol * np.finfo(float).eps + R_mean = self[0] # initial guess + while True: + r = np.dstack((R_mean.inv() * self).log()).mean(axis=2) + if np.linalg.norm(r) < eta: + return R_mean + R_mean = R_mean @ self.Exp(r) # update estimate and normalize + # ============================== SE3 =====================================# diff --git a/spatialmath/quaternion.py b/spatialmath/quaternion.py index 51561036..3633646b 100644 --- a/spatialmath/quaternion.py +++ b/spatialmath/quaternion.py @@ -45,10 +45,10 @@ def __init__(self, s: Any = None, v=None, check: Optional[bool] = True): r""" Construct a new quaternion - :param s: scalar - :type s: float - :param v: vector - :type v: 3-element array_like + :param s: scalar part + :type s: float or ndarray(N) + :param v: vector part + :type v: ndarray(3), ndarray(Nx3) - ``Quaternion()`` constructs a zero quaternion - ``Quaternion(s, v)`` construct a new quaternion from the scalar ``s`` @@ -78,7 +78,7 @@ def __init__(self, s: Any = None, v=None, check: Optional[bool] = True): super().__init__() if s is None and smb.isvector(v, 4): - v,s = (s,v) + v, s = (s, v) if v is None: # single argument @@ -92,6 +92,11 @@ def __init__(self, s: Any = None, v=None, check: Optional[bool] = True): # Quaternion(s, v) self.data = [np.r_[s, smb.getvector(v)]] + elif ( + smb.isvector(s) and smb.ismatrix(v, (None, 3)) and s.shape[0] == v.shape[0] + ): + # Quaternion(s, v) where s and v are arrays + self.data = [np.r_[_s, _v] for _s, _v in zip(s, v)] else: raise ValueError("bad argument to Quaternion constructor") @@ -395,9 +400,23 @@ def log(self) -> Quaternion: :seealso: :meth:`Quaternion.exp` :meth:`Quaternion.log` :meth:`UnitQuaternion.angvec` """ norm = self.norm() - s = math.log(norm) - v = math.acos(np.clip(self.s / norm, -1, 1)) * smb.unitvec(self.v) - return Quaternion(s=s, v=v) + s = np.log(norm) + if len(self) == 1: + if smb.iszerovec(self._A[1:4]): + v = np.zeros((3,)) + else: + v = math.acos(np.clip(self._A[0] / norm, -1, 1)) * smb.unitvec( + self._A[1:4] + ) + return Quaternion(s=s, v=v) + else: + v = [ + np.zeros((3,)) + if smb.iszerovec(A[1:4]) + else math.acos(np.clip(A[0] / n, -1, 1)) * smb.unitvec(A[1:4]) + for A, n in zip(self._A, norm) + ] + return Quaternion(s=s, v=np.array(v)) def exp(self, tol: float = 20) -> Quaternion: r""" @@ -437,7 +456,11 @@ def exp(self, tol: float = 20) -> Quaternion: exp_s = math.exp(self.s) norm_v = smb.norm(self.v) s = exp_s * math.cos(norm_v) - v = exp_s * self.v / norm_v * math.sin(norm_v) + if smb.iszerovec(self.v, tol * _eps): + # result will be a unit quaternion + v = np.zeros((3,)) + else: + v = exp_s * self.v / norm_v * math.sin(norm_v) if abs(self.s) < tol * _eps: # result will be a unit quaternion return UnitQuaternion(s=s, v=v) @@ -1260,7 +1283,7 @@ def Eul(cls, *angles: List[float], unit: Optional[str] = "rad") -> UnitQuaternio Construct a new unit quaternion from Euler angles :param 𝚪: 3-vector of Euler angles - :type 𝚪: array_like + :type 𝚪: 3 floats, array_like(3) or ndarray(N,3) :param unit: angular units: 'rad' [default], or 'deg' :type unit: str :return: unit-quaternion @@ -1286,12 +1309,15 @@ def Eul(cls, *angles: List[float], unit: Optional[str] = "rad") -> UnitQuaternio if len(angles) == 1: angles = angles[0] - return cls(smb.r2q(smb.eul2r(angles, unit=unit)), check=False) + if smb.isvector(angles, 3): + return cls(smb.r2q(smb.eul2r(angles, unit=unit)), check=False) + else: + return cls([smb.r2q(smb.eul2r(a, unit=unit)) for a in angles], check=False) @classmethod def RPY( cls, - *angles: List[float], + *angles, order: Optional[str] = "zyx", unit: Optional[str] = "rad", ) -> UnitQuaternion: @@ -1299,7 +1325,7 @@ def RPY( Construct a new unit quaternion from roll-pitch-yaw angles :param 𝚪: 3-vector of roll-pitch-yaw angles - :type 𝚪: array_like + :type 𝚪: 3 floats, array_like(3) or ndarray(N,3) :param unit: angular units: 'rad' [default], or 'deg' :type unit: str :param unit: rotation order: 'zyx' [default], 'xyz', or 'yxz' @@ -1341,7 +1367,13 @@ def RPY( if len(angles) == 1: angles = angles[0] - return cls(smb.r2q(smb.rpy2r(angles, unit=unit, order=order)), check=False) + if smb.isvector(angles, 3): + return cls(smb.r2q(smb.rpy2r(angles, unit=unit, order=order)), check=False) + else: + return cls( + [smb.r2q(smb.rpy2r(a, unit=unit, order=order)) for a in angles], + check=False, + ) @classmethod def OA(cls, o: ArrayLike3, a: ArrayLike3) -> UnitQuaternion: @@ -1569,6 +1601,24 @@ def dotb(self, omega: ArrayLike3) -> R4: """ return smb.qdotb(self._A, omega) + # def mean(self, tol: float = 20) -> SO3: + # """Mean of a set of rotations + + # :param tol: iteration tolerance in units of eps, defaults to 20 + # :type tol: float, optional + # :return: the mean rotation + # :rtype: :class:`UnitQuaternion` instance. + + # Computes the Karcher mean of the set of rotations within the unit quaternion instance. + + # :references: + # - `**Hartley, Trumpf** - "Rotation Averaging" - IJCV 2011 `_ + # - `Karcher mean UnitQuaternion: # lgtm[py/not-named-self] pylint: disable=no-self-argument diff --git a/tests/test_pose3d.py b/tests/test_pose3d.py index d6a941c3..ca03b70e 100755 --- a/tests/test_pose3d.py +++ b/tests/test_pose3d.py @@ -717,6 +717,37 @@ def test_functions_lie(self): nt.assert_equal(R, SO3.EulerVec(R.eulervec())) np.testing.assert_equal((R.inv() * R).eulervec(), np.zeros(3)) + R = SO3() # identity matrix case + + # Check log and exponential map + nt.assert_equal(R, SO3.Exp(R.log())) + np.testing.assert_equal((R.inv() * R).log(), np.zeros([3, 3])) + + # Check euler vector map + nt.assert_equal(R, SO3.EulerVec(R.eulervec())) + np.testing.assert_equal((R.inv() * R).eulervec(), np.zeros(3)) + + def test_mean(self): + rpy = np.ones((100, 1)) @ np.c_[0.1, 0.2, 0.3] + R = SO3.RPY(rpy) + self.assertEqual(len(R), 100) + m = R.mean() + self.assertIsInstance(m, SO3) + array_compare(m, R[0]) + + # range of angles, mean should be the middle one, index=25 + R = SO3.Rz(np.linspace(start=0.3, stop=0.7, num=51)) + m = R.mean() + self.assertIsInstance(m, SO3) + array_compare(m, R[25]) + + # now add noise + rng = np.random.default_rng(0) # reproducible random numbers + rpy += rng.normal(scale=0.00001, size=(100, 3)) + R = SO3.RPY(rpy) + m = R.mean() + array_compare(m, SO3.RPY(0.1, 0.2, 0.3)) + # ============================== SE3 =====================================# diff --git a/tests/test_quaternion.py b/tests/test_quaternion.py index 73c1b090..403b3c8d 100644 --- a/tests/test_quaternion.py +++ b/tests/test_quaternion.py @@ -257,18 +257,55 @@ def test_staticconstructors(self): UnitQuaternion.Rz(theta, "deg").R, rotz(theta, "deg") ) + def test_constructor_RPY(self): # 3 angle + q = UnitQuaternion.RPY([0.1, 0.2, 0.3]) + self.assertIsInstance(q, UnitQuaternion) + self.assertEqual(len(q), 1) + nt.assert_array_almost_equal(q.R, rpy2r(0.1, 0.2, 0.3)) + q = UnitQuaternion.RPY(0.1, 0.2, 0.3) + self.assertIsInstance(q, UnitQuaternion) + self.assertEqual(len(q), 1) + nt.assert_array_almost_equal(q.R, rpy2r(0.1, 0.2, 0.3)) + q = UnitQuaternion.RPY(np.r_[0.1, 0.2, 0.3]) + self.assertIsInstance(q, UnitQuaternion) + self.assertEqual(len(q), 1) + nt.assert_array_almost_equal(q.R, rpy2r(0.1, 0.2, 0.3)) + nt.assert_array_almost_equal( - UnitQuaternion.RPY([0.1, 0.2, 0.3]).R, rpy2r(0.1, 0.2, 0.3) + UnitQuaternion.RPY([10, 20, 30], unit="deg").R, + rpy2r(10, 20, 30, unit="deg"), ) - nt.assert_array_almost_equal( - UnitQuaternion.Eul([0.1, 0.2, 0.3]).R, eul2r(0.1, 0.2, 0.3) + UnitQuaternion.RPY([0.1, 0.2, 0.3], order="xyz").R, + rpy2r(0.1, 0.2, 0.3, order="xyz"), + ) + + angles = np.array( + [[0.1, 0.2, 0.3], [0.2, 0.3, 0.4], [0.3, 0.4, 0.5], [0.4, 0.5, 0.6]] ) + q = UnitQuaternion.RPY(angles) + self.assertIsInstance(q, UnitQuaternion) + self.assertEqual(len(q), 4) + for i in range(4): + nt.assert_array_almost_equal(q[i].R, rpy2r(angles[i, :])) + + q = UnitQuaternion.RPY(angles, order="xyz") + self.assertIsInstance(q, UnitQuaternion) + self.assertEqual(len(q), 4) + for i in range(4): + nt.assert_array_almost_equal(q[i].R, rpy2r(angles[i, :], order="xyz")) + angles *= 10 + q = UnitQuaternion.RPY(angles, unit="deg") + self.assertIsInstance(q, UnitQuaternion) + self.assertEqual(len(q), 4) + for i in range(4): + nt.assert_array_almost_equal(q[i].R, rpy2r(angles[i, :], unit="deg")) + + def test_constructor_Eul(self): nt.assert_array_almost_equal( - UnitQuaternion.RPY([10, 20, 30], unit="deg").R, - rpy2r(10, 20, 30, unit="deg"), + UnitQuaternion.Eul([0.1, 0.2, 0.3]).R, eul2r(0.1, 0.2, 0.3) ) nt.assert_array_almost_equal( @@ -276,6 +313,23 @@ def test_staticconstructors(self): eul2r(10, 20, 30, unit="deg"), ) + angles = np.array( + [[0.1, 0.2, 0.3], [0.2, 0.3, 0.4], [0.3, 0.4, 0.5], [0.4, 0.5, 0.6]] + ) + q = UnitQuaternion.Eul(angles) + self.assertIsInstance(q, UnitQuaternion) + self.assertEqual(len(q), 4) + for i in range(4): + nt.assert_array_almost_equal(q[i].R, eul2r(angles[i, :])) + + angles *= 10 + q = UnitQuaternion.Eul(angles, unit="deg") + self.assertIsInstance(q, UnitQuaternion) + self.assertEqual(len(q), 4) + for i in range(4): + nt.assert_array_almost_equal(q[i].R, eul2r(angles[i, :], unit="deg")) + + def test_constructor_AngVec(self): # (theta, v) th = 0.2 v = unitvec([1, 2, 3]) @@ -286,6 +340,7 @@ def test_staticconstructors(self): ) nt.assert_array_almost_equal(UnitQuaternion.AngVec(th, -v).R, angvec2r(th, -v)) + def test_constructor_EulerVec(self): # (theta, v) th = 0.2 v = unitvec([1, 2, 3]) @@ -830,6 +885,20 @@ def test_log(self): nt.assert_array_almost_equal(q1.log().exp(), q1) nt.assert_array_almost_equal(q2.log().exp(), q2) + q = Quaternion([q1, q2, q1, q2]) + qlog = q.log() + nt.assert_array_almost_equal(qlog[0].exp(), q1) + nt.assert_array_almost_equal(qlog[1].exp(), q2) + nt.assert_array_almost_equal(qlog[2].exp(), q1) + nt.assert_array_almost_equal(qlog[3].exp(), q2) + + q = UnitQuaternion() # identity + qlog = q.log() + nt.assert_array_almost_equal(qlog.vec, np.zeros(4)) + qq = qlog.exp() + self.assertIsInstance(qq, UnitQuaternion) + nt.assert_array_almost_equal(qq.vec, np.r_[1, 0, 0, 0]) + def test_concat(self): u = Quaternion() uu = Quaternion([u, u, u, u]) @@ -1018,6 +1087,27 @@ def test_miscellany(self): nt.assert_equal(q.inner(q), q.norm() ** 2) nt.assert_equal(q.inner(u), np.dot(q.vec, u.vec)) + # def test_mean(self): + # rpy = np.ones((100, 1)) @ np.c_[0.1, 0.2, 0.3] + # q = UnitQuaternion.RPY(rpy) + # self.assertEqual(len(q), 100) + # m = q.mean() + # self.assertIsInstance(m, UnitQuaternion) + # nt.assert_array_almost_equal(m.vec, q[0].vec) + + # # range of angles, mean should be the middle one, index=25 + # q = UnitQuaternion.Rz(np.linspace(start=0.3, stop=0.7, num=51)) + # m = q.mean() + # self.assertIsInstance(m, UnitQuaternion) + # nt.assert_array_almost_equal(m.vec, q[25].vec) + + # # now add noise + # rng = np.random.default_rng(0) # reproducible random numbers + # rpy += rng.normal(scale=0.1, size=(100, 3)) + # q = UnitQuaternion.RPY(rpy) + # m = q.mean() + # nt.assert_array_almost_equal(m.vec, q.RPY(0.1, 0.2, 0.3).vec) + # ---------------------------------------------------------------------------------------# if __name__ == "__main__": From 8c6d42262941de10e453237cc39940f9042e27ca Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Sun, 26 Jan 2025 17:00:26 +1100 Subject: [PATCH 07/14] Add methods to convert SE3 to/from the rvec, tvec format used by OpenCV (#157) --- spatialmath/pose3d.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_pose3d.py | 11 ++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/spatialmath/pose3d.py b/spatialmath/pose3d.py index 4e558512..b42d752c 100644 --- a/spatialmath/pose3d.py +++ b/spatialmath/pose3d.py @@ -1315,6 +1315,21 @@ def delta(self, X2: Optional[SE3] = None) -> R6: else: return smb.tr2delta(self.A, X2.A) + def rtvec(self) -> Tuple[R3, R3]: + """ + Convert to OpenCV-style rotation and translation vectors + + :return: rotation and translation vectors + :rtype: ndarray(3), ndarray(3) + + Many OpenCV functions accept pose as two 3-vectors: a rotation vector using + exponential coordinates and a translation vector. This method combines them + into an SE(3) instance. + + :seealso: :meth:`rtvec` + """ + return SO3(self).log(twist=True), self.t + def Ad(self) -> R6x6: r""" Adjoint of SE(3) @@ -1856,6 +1871,26 @@ def Exp(cls, S: Union[R6, R4x4], check: bool = True) -> SE3: else: return cls(smb.trexp(S), check=False) + @classmethod + def RTvec(cls, rvec: ArrayLike3, tvec: ArrayLike3) -> Self: + """ + Construct a new SE(3) from OpenCV-style rotation and translation vectors + + :param rvec: rotation as exponential coordinates + :type rvec: ArrayLike3 + :param tvec: translation vector + :type tvec: ArrayLike3 + :return: An SE(3) instance + :rtype: SE3 instance + + Many OpenCV functions (such as pose estimation) return pose as two 3-vectors: a + rotation vector using exponential coordinates and a translation vector. This + method combines them into an SE(3) instance. + + :seealso: :meth:`rtvec` + """ + return SE3.Rt(smb.trexp(rvec), tvec) + @classmethod def Delta(cls, d: ArrayLike6) -> SE3: r""" diff --git a/tests/test_pose3d.py b/tests/test_pose3d.py index ca03b70e..86d4c414 100755 --- a/tests/test_pose3d.py +++ b/tests/test_pose3d.py @@ -254,7 +254,6 @@ def test_conversion(self): nt.assert_array_almost_equal(q.SO3(), R_from_q) nt.assert_array_almost_equal(q.SO3().UnitQuaternion(), q) - def test_shape(self): a = SO3() self.assertEqual(a._A.shape, a.shape) @@ -1370,6 +1369,16 @@ def test_functions_vect(self): # .T pass + def test_rtvec(self): + # OpenCV compatibility functions + T = SE3.RTvec([0, 1, 0], [2, 3, 4]) + nt.assert_equal(T.t, [2, 3, 4]) + nt.assert_equal(T.R, SO3.Ry(1)) + + rvec, tvec = T.rtvec() + nt.assert_equal(rvec, [0, 1, 0]) + nt.assert_equal(tvec, [2, 3, 4]) + # ---------------------------------------------------------------------------------------# if __name__ == "__main__": From a08f25ed97d46db6bd5098d76797164e571f7702 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Sun, 26 Jan 2025 17:02:17 +1100 Subject: [PATCH 08/14] Add `q2str` to convert quaternion to string (#158) --- spatialmath/base/__init__.py | 1 + spatialmath/base/quaternions.py | 65 ++++++++++++++++++++++++++------- spatialmath/quaternion.py | 2 +- tests/base/test_quaternions.py | 36 ++++++++++++------ 4 files changed, 79 insertions(+), 25 deletions(-) diff --git a/spatialmath/base/__init__.py b/spatialmath/base/__init__.py index 98ff87f0..9e9fbcbe 100644 --- a/spatialmath/base/__init__.py +++ b/spatialmath/base/__init__.py @@ -208,6 +208,7 @@ "qdotb", "qangle", "qprint", + "q2str", # spatialmath.base.transforms2d "rot2", "trot2", diff --git a/spatialmath/base/quaternions.py b/spatialmath/base/quaternions.py index 8f33bc1c..d5652d4a 100755 --- a/spatialmath/base/quaternions.py +++ b/spatialmath/base/quaternions.py @@ -19,9 +19,11 @@ import scipy.interpolate as interpolate from typing import Optional from functools import lru_cache +import warnings _eps = np.finfo(np.float64).eps + def qeye() -> QuaternionArray: """ Create an identity quaternion @@ -56,7 +58,7 @@ def qpure(v: ArrayLike3) -> QuaternionArray: .. runblock:: pycon - >>> from spatialmath.base import pure, qprint + >>> from spatialmath.base import qpure, qprint >>> q = qpure([1, 2, 3]) >>> qprint(q) """ @@ -1088,14 +1090,53 @@ def qangle(q1: ArrayLike4, q2: ArrayLike4) -> float: return 4.0 * math.atan2(smb.norm(q1 - q2), smb.norm(q1 + q2)) +def q2str( + q: Union[ArrayLike4, ArrayLike4], + delim: Optional[Tuple[str, str]] = ("<", ">"), + fmt: Optional[str] = "{: .4f}", +) -> str: + """ + Format a quaternion as a string + + :arg q: unit-quaternion + :type q: array_like(4) + :arg delim: 2-list of delimeters [default ('<', '>')] + :type delim: list or tuple of strings + :arg fmt: printf-style format soecifier [default '{: .4f}'] + :type fmt: str + :return: formatted string + :rtype: str + + Format the quaternion in a human-readable form as:: + + S D1 VX VY VZ D2 + + where S, VX, VY, VZ are the quaternion elements, and D1 and D2 are a pair + of delimeters given by `delim`. + + .. runblock:: pycon + + >>> from spatialmath.base import q2str, qrand + >>> q = [1, 2, 3, 4] + >>> q2str(q) + >>> q = qrand() # a unit quaternion + >>> q2str(q, delim=('<<', '>>')) + + :seealso: :meth:`qprint` + """ + q = smb.getvector(q, 4) + template = "# {} #, #, # {}".replace("#", fmt) + return template.format(q[0], delim[0], q[1], q[2], q[3], delim[1]) + + def qprint( q: Union[ArrayLike4, ArrayLike4], delim: Optional[Tuple[str, str]] = ("<", ">"), fmt: Optional[str] = "{: .4f}", file: Optional[TextIO] = sys.stdout, -) -> str: +) -> None: """ - Format a quaternion + Format a quaternion to a file :arg q: unit-quaternion :type q: array_like(4) @@ -1105,8 +1146,6 @@ def qprint( :type fmt: str :arg file: destination for formatted string [default sys.stdout] :type file: file object - :return: formatted string - :rtype: str Format the quaternion in a human-readable form as:: @@ -1117,8 +1156,6 @@ def qprint( By default the string is written to `sys.stdout`. - If `file=None` then a string is returned. - .. runblock:: pycon >>> from spatialmath.base import qprint, qrand @@ -1126,14 +1163,16 @@ def qprint( >>> qprint(q) >>> q = qrand() # a unit quaternion >>> qprint(q, delim=('<<', '>>')) + + :seealso: :meth:`q2str` """ q = smb.getvector(q, 4) - template = "# {} #, #, # {}".replace("#", fmt) - s = template.format(q[0], delim[0], q[1], q[2], q[3], delim[1]) - if file: - file.write(s + "\n") - else: - return s + if file is None: + warnings.warn( + "Usage: qprint(..., file=None) -> str is deprecated, use q2str() instead", + DeprecationWarning, + ) + print(q2str(q, delim=delim, fmt=fmt), file=file) if __name__ == "__main__": # pragma: no cover diff --git a/spatialmath/quaternion.py b/spatialmath/quaternion.py index 3633646b..f9b73873 100644 --- a/spatialmath/quaternion.py +++ b/spatialmath/quaternion.py @@ -920,7 +920,7 @@ def __str__(self) -> str: delim = ("<<", ">>") else: delim = ("<", ">") - return "\n".join([smb.qprint(q, file=None, delim=delim) for q in self.data]) + return "\n".join([smb.q2str(q, delim=delim) for q in self.data]) # ========================================================================= # diff --git a/tests/base/test_quaternions.py b/tests/base/test_quaternions.py index c512c6d2..f5859b54 100644 --- a/tests/base/test_quaternions.py +++ b/tests/base/test_quaternions.py @@ -36,6 +36,7 @@ import spatialmath.base as tr from spatialmath.base.quaternions import * import spatialmath as sm +import io class TestQuaternion(unittest.TestCase): @@ -96,19 +97,32 @@ def test_ops(self): ), True, ) + nt.assert_equal(isunitvec(qrand()), True) - s = qprint(np.r_[1, 1, 0, 0], file=None) - nt.assert_equal(isinstance(s, str), True) - nt.assert_equal(len(s) > 2, True) - s = qprint([1, 1, 0, 0], file=None) + def test_display(self): + s = q2str(np.r_[1, 2, 3, 4]) nt.assert_equal(isinstance(s, str), True) - nt.assert_equal(len(s) > 2, True) + nt.assert_equal(s, " 1.0000 < 2.0000, 3.0000, 4.0000 >") + + s = q2str([1, 2, 3, 4]) + nt.assert_equal(s, " 1.0000 < 2.0000, 3.0000, 4.0000 >") + s = q2str([1, 2, 3, 4], delim=("<<", ">>")) + nt.assert_equal(s, " 1.0000 << 2.0000, 3.0000, 4.0000 >>") + + s = q2str([1, 2, 3, 4], fmt="{:20.6f}") nt.assert_equal( - qprint([1, 2, 3, 4], file=None), " 1.0000 < 2.0000, 3.0000, 4.0000 >" + s, + " 1.000000 < 2.000000, 3.000000, 4.000000 >", ) - nt.assert_equal(isunitvec(qrand()), True) + # would be nicer to do this with redirect_stdout() from contextlib but that + # fails because file=sys.stdout is maybe assigned at compile time, so when + # contextlib changes sys.stdout, qprint() doesn't see it + + f = io.StringIO() + qprint(np.r_[1, 2, 3, 4], file=f) + nt.assert_equal(f.getvalue().rstrip(), " 1.0000 < 2.0000, 3.0000, 4.0000 >") def test_rotation(self): # rotation matrix to quaternion @@ -227,12 +241,12 @@ def test_r2q(self): def test_qangle(self): # Test function that calculates angle between quaternions - q1 = [1., 0, 0, 0] - q2 = [1 / np.sqrt(2), 0, 1 / np.sqrt(2), 0] # 90deg rotation about y-axis + q1 = [1.0, 0, 0, 0] + q2 = [1 / np.sqrt(2), 0, 1 / np.sqrt(2), 0] # 90deg rotation about y-axis nt.assert_almost_equal(qangle(q1, q2), np.pi / 2) - q1 = [1., 0, 0, 0] - q2 = [1 / np.sqrt(2), 1 / np.sqrt(2), 0, 0] # 90deg rotation about x-axis + q1 = [1.0, 0, 0, 0] + q2 = [1 / np.sqrt(2), 1 / np.sqrt(2), 0, 0] # 90deg rotation about x-axis nt.assert_almost_equal(qangle(q1, q2), np.pi / 2) From 659ba24a80cca2d330b983a80b7f62ca66a262ce Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Fri, 31 Jan 2025 04:26:47 +1100 Subject: [PATCH 09/14] new method to construct an SO3 object (#159) Co-authored-by: Mark Yeatman <129521731+myeatman-bdai@users.noreply.github.com> --- spatialmath/pose3d.py | 42 ++++++++++++++++++++++++++++++++++++++++++ tests/test_pose3d.py | 12 ++++++++++++ 2 files changed, 54 insertions(+) diff --git a/spatialmath/pose3d.py b/spatialmath/pose3d.py index b42d752c..3b4821e5 100644 --- a/spatialmath/pose3d.py +++ b/spatialmath/pose3d.py @@ -725,6 +725,48 @@ def vval(v): return cls(np.c_[x, y, z], check=True) + @classmethod + def RotatedVector(cls, v1: ArrayLike3, v2: ArrayLike3, tol=20) -> Self: + """ + Construct a new SO(3) from a vector and its rotated image + + :param v1: initial vector + :type v1: array_like(3) + :param v2: vector after rotation + :type v2: array_like(3) + :param tol: tolerance for singularity in units of eps, defaults to 20 + :type tol: float + :return: SO(3) rotation + :rtype: :class:`SO3` instance + + ``SO3.RotatedVector(v1, v2)`` is an SO(3) rotation defined in terms of + two vectors. The rotation takes vector ``v1`` to ``v2``. + + .. runblock:: pycon + + >>> from spatialmath import SO3 + >>> v1 = [1, 2, 3] + >>> v2 = SO3.Eul(0.3, 0.4, 0.5) * v1 + >>> print(v2) + >>> R = SO3.RotatedVector(v1, v2) + >>> print(R) + >>> print(R * v1) + + .. note:: The vectors do not have to be unit-length. + """ + # https://math.stackexchange.com/questions/180418/calculate-rotation-matrix-to-align-vector-a-to-vector-b-in-3d + v1 = smb.unitvec(v1) + v2 = smb.unitvec(v2) + v = smb.cross(v1, v2) + s = smb.norm(v) + if abs(s) < tol * np.finfo(float).eps: + return cls(np.eye(3), check=False) + else: + c = np.dot(v1, v2) + V = smb.skew(v) + R = np.eye(3) + V + V @ V * (1 - c) / (s**2) + return cls(R, check=False) + @classmethod def AngleAxis(cls, theta: float, v: ArrayLike3, *, unit: str = "rad") -> Self: r""" diff --git a/tests/test_pose3d.py b/tests/test_pose3d.py index 86d4c414..58396441 100755 --- a/tests/test_pose3d.py +++ b/tests/test_pose3d.py @@ -716,6 +716,17 @@ def test_functions_lie(self): nt.assert_equal(R, SO3.EulerVec(R.eulervec())) np.testing.assert_equal((R.inv() * R).eulervec(), np.zeros(3)) + + def test_rotatedvector(self): + v1 = [1, 2, 3] + R = SO3.Eul(0.3, 0.4, 0.5) + v2 = R * v1 + Re = SO3.RotatedVector(v1, v2) + np.testing.assert_almost_equal(v2, Re * v1) + + Re = SO3.RotatedVector(v1, v1) + np.testing.assert_almost_equal(Re, np.eye(3)) + R = SO3() # identity matrix case # Check log and exponential map @@ -748,6 +759,7 @@ def test_mean(self): array_compare(m, SO3.RPY(0.1, 0.2, 0.3)) + # ============================== SE3 =====================================# From 39d6127465432823a386deae2d7fa356de1125e2 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Fri, 31 Jan 2025 04:27:13 +1100 Subject: [PATCH 10/14] Fix for issue #144. (#155) --- spatialmath/base/argcheck.py | 50 ++++++++++++++++++++++---------- spatialmath/base/transforms2d.py | 2 +- spatialmath/base/transforms3d.py | 35 +++++++++++++--------- spatialmath/base/vectors.py | 18 ++++++++---- spatialmath/quaternion.py | 2 +- tests/base/test_argcheck.py | 42 +++++++++++++++++++++++++-- 6 files changed, 109 insertions(+), 40 deletions(-) diff --git a/spatialmath/base/argcheck.py b/spatialmath/base/argcheck.py index 40f94336..38b5eb1a 100644 --- a/spatialmath/base/argcheck.py +++ b/spatialmath/base/argcheck.py @@ -522,7 +522,9 @@ def isvector(v: Any, dim: Optional[int] = None) -> bool: return False -def getunit(v: ArrayLike, unit: str = "rad", dim=None) -> Union[float, NDArray]: +def getunit( + v: ArrayLike, unit: str = "rad", dim: Optional[int] = None, vector: bool = True +) -> Union[float, NDArray]: """ Convert values according to angular units @@ -530,8 +532,10 @@ def getunit(v: ArrayLike, unit: str = "rad", dim=None) -> Union[float, NDArray]: :type v: array_like(m) :param unit: the angular unit, "rad" or "deg" :type unit: str - :param dim: expected dimension of input, defaults to None + :param dim: expected dimension of input, defaults to don't check (None) :type dim: int, optional + :param vector: return a scalar as a 1d vector, defaults to True + :type vector: bool, optional :return: the converted value in radians :rtype: ndarray(m) or float :raises ValueError: argument is not a valid angular unit @@ -543,30 +547,44 @@ def getunit(v: ArrayLike, unit: str = "rad", dim=None) -> Union[float, NDArray]: >>> from spatialmath.base import getunit >>> import numpy as np >>> getunit(1.5, 'rad') - >>> getunit(1.5, 'rad', dim=0) - >>> # getunit([1.5], 'rad', dim=0) --> ValueError >>> getunit(90, 'deg') + >>> getunit(90, 'deg', vector=False) # force a scalar output + >>> getunit(1.5, 'rad', dim=0) # check argument is scalar + >>> getunit(1.5, 'rad', dim=3) # check argument is a 3-vector + >>> getunit([1.5], 'rad', dim=1) # check argument is a 1-vector + >>> getunit([1.5], 'rad', dim=3) # check argument is a 3-vector >>> getunit([90, 180], 'deg') - >>> getunit(np.r_[0.5, 1], 'rad') >>> getunit(np.r_[90, 180], 'deg') - >>> getunit(np.r_[90, 180], 'deg', dim=2) - >>> # getunit([90, 180], 'deg', dim=3) --> ValueError + >>> getunit(np.r_[90, 180], 'deg', dim=2) # check argument is a 2-vector + >>> getunit([90, 180], 'deg', dim=3) # check argument is a 3-vector :note: - the input value is processed by :func:`getvector` and the argument ``dim`` can - be used to check that ``v`` is the desired length. - - the output is always an ndarray except if the input is a scalar and ``dim=0``. + be used to check that ``v`` is the desired length. Note that 0 means a scalar, + whereas 1 means a 1-element array. + - the output is always an ndarray except if the input is a scalar and ``vector=False``. :seealso: :func:`getvector` """ - if not isinstance(v, Iterable) and dim == 0: - # scalar in, scalar out - if unit == "rad": - return v - elif unit == "deg": - return np.deg2rad(v) + if not isinstance(v, Iterable): + # scalar input + if dim is not None and dim != 0: + raise ValueError("for dim==0 input must be a scalar") + if vector: + # scalar in, vector out + if unit == "deg": + v = np.deg2rad(v) + elif unit != "rad": + raise ValueError("invalid angular units") + return np.array([v]) else: - raise ValueError("invalid angular units") + # scalar in, scalar out + if unit == "rad": + return v + elif unit == "deg": + return np.deg2rad(v) + else: + raise ValueError("invalid angular units") else: # scalar or iterable in, ndarray out diff --git a/spatialmath/base/transforms2d.py b/spatialmath/base/transforms2d.py index 682ea0ca..25265fff 100644 --- a/spatialmath/base/transforms2d.py +++ b/spatialmath/base/transforms2d.py @@ -63,7 +63,7 @@ def rot2(theta: float, unit: str = "rad") -> SO2Array: >>> rot2(0.3) >>> rot2(45, 'deg') """ - theta = smb.getunit(theta, unit, dim=0) + theta = smb.getunit(theta, unit, vector=False) ct = smb.sym.cos(theta) st = smb.sym.sin(theta) # fmt: off diff --git a/spatialmath/base/transforms3d.py b/spatialmath/base/transforms3d.py index 3617f965..350e1d14 100644 --- a/spatialmath/base/transforms3d.py +++ b/spatialmath/base/transforms3d.py @@ -79,7 +79,7 @@ def rotx(theta: float, unit: str = "rad") -> SO3Array: :SymPy: supported """ - theta = getunit(theta, unit, dim=0) + theta = getunit(theta, unit, vector=False) ct = sym.cos(theta) st = sym.sin(theta) # fmt: off @@ -118,7 +118,7 @@ def roty(theta: float, unit: str = "rad") -> SO3Array: :SymPy: supported """ - theta = getunit(theta, unit, dim=0) + theta = getunit(theta, unit, vector=False) ct = sym.cos(theta) st = sym.sin(theta) # fmt: off @@ -152,7 +152,7 @@ def rotz(theta: float, unit: str = "rad") -> SO3Array: :seealso: :func:`~trotz` :SymPy: supported """ - theta = getunit(theta, unit, dim=0) + theta = getunit(theta, unit, vector=False) ct = sym.cos(theta) st = sym.sin(theta) # fmt: off @@ -2709,7 +2709,7 @@ def tr2adjoint(T): :Reference: - Robotics, Vision & Control for Python, Section 3, P. Corke, Springer 2023. - - `Lie groups for 2D and 3D Transformations _ + - `Lie groups for 2D and 3D Transformations `_ :SymPy: supported """ @@ -3002,29 +3002,36 @@ def trplot( - ``width`` of line - ``length`` of line - ``style`` which is one of: + - ``'arrow'`` [default], draw line with arrow head in ``color`` - ``'line'``, draw line with no arrow head in ``color`` - ``'rgb'``, frame axes are lines with no arrow head and red for X, green - for Y, blue for Z; no origin dot + for Y, blue for Z; no origin dot - ``'rviz'``, frame axes are thick lines with no arrow head and red for X, - green for Y, blue for Z; no origin dot + green for Y, blue for Z; no origin dot + - coordinate axis labels depend on: + - ``axislabel`` if True [default] label the axis, default labels are X, Y, Z - ``labels`` 3-list of alternative axis labels - ``textcolor`` which defaults to ``color`` - ``axissubscript`` if True [default] add the frame label ``frame`` as a subscript - for each axis label + for each axis label + - coordinate frame label depends on: + - `frame` the label placed inside {} near the origin of the frame + - a dot at the origin + - ``originsize`` size of the dot, if zero no dot - ``origincolor`` color of the dot, defaults to ``color`` Examples:: - trplot(T, frame='A') - trplot(T, frame='A', color='green') - trplot(T1, 'labels', 'UVW'); + trplot(T, frame='A') + trplot(T, frame='A', color='green') + trplot(T1, 'labels', 'UVW'); .. plot:: @@ -3383,12 +3390,12 @@ def tranimate(T: Union[SO3Array, SE3Array], **kwargs) -> str: :param **kwargs: arguments passed to ``trplot`` - ``tranimate(T)`` where ``T`` is an SO(3) or SE(3) matrix, animates a 3D - coordinate frame moving from the world frame to the frame ``T`` in - ``nsteps``. + coordinate frame moving from the world frame to the frame ``T`` in + ``nsteps``. - ``tranimate(I)`` where ``I`` is an iterable or generator, animates a 3D - coordinate frame representing the pose of each element in the sequence of - SO(3) or SE(3) matrices. + coordinate frame representing the pose of each element in the sequence of + SO(3) or SE(3) matrices. Examples: diff --git a/spatialmath/base/vectors.py b/spatialmath/base/vectors.py index f29740a3..bf95283f 100644 --- a/spatialmath/base/vectors.py +++ b/spatialmath/base/vectors.py @@ -530,6 +530,7 @@ def wrap_0_pi(theta: ArrayLike) -> Union[float, NDArray]: :param theta: input angle :type theta: scalar or ndarray :return: angle wrapped into range :math:`[0, \pi)` + :rtype: scalar or ndarray This is used to fold angles of colatitude. If zero is the angle of the north pole, colatitude increases to :math:`\pi` at the south pole then @@ -537,7 +538,7 @@ def wrap_0_pi(theta: ArrayLike) -> Union[float, NDArray]: :seealso: :func:`wrap_mpi2_pi2` :func:`wrap_0_2pi` :func:`wrap_mpi_pi` :func:`angle_wrap` """ - theta = np.abs(theta) + theta = np.abs(getvector(theta)) n = theta / np.pi if isinstance(n, np.ndarray): n = n.astype(int) @@ -546,7 +547,7 @@ def wrap_0_pi(theta: ArrayLike) -> Union[float, NDArray]: y = np.where(np.bitwise_and(n, 1) == 0, theta - n * np.pi, (n + 1) * np.pi - theta) if isinstance(y, np.ndarray) and y.size == 1: - return float(y) + return float(y[0]) else: return y @@ -558,6 +559,7 @@ def wrap_mpi2_pi2(theta: ArrayLike) -> Union[float, NDArray]: :param theta: input angle :type theta: scalar or ndarray :return: angle wrapped into range :math:`[-\pi/2, \pi/2]` + :rtype: scalar or ndarray This is used to fold angles of latitude. @@ -573,7 +575,7 @@ def wrap_mpi2_pi2(theta: ArrayLike) -> Union[float, NDArray]: y = np.where(np.bitwise_and(n, 1) == 0, theta - n * np.pi, n * np.pi - theta) if isinstance(y, np.ndarray) and len(y) == 1: - return float(y) + return float(y[0]) else: return y @@ -585,13 +587,14 @@ def wrap_0_2pi(theta: ArrayLike) -> Union[float, NDArray]: :param theta: input angle :type theta: scalar or ndarray :return: angle wrapped into range :math:`[0, 2\pi)` + :rtype: scalar or ndarray :seealso: :func:`wrap_mpi_pi` :func:`wrap_0_pi` :func:`wrap_mpi2_pi2` :func:`angle_wrap` """ theta = getvector(theta) y = theta - 2.0 * math.pi * np.floor(theta / 2.0 / np.pi) if isinstance(y, np.ndarray) and len(y) == 1: - return float(y) + return float(y[0]) else: return y @@ -603,13 +606,14 @@ def wrap_mpi_pi(theta: ArrayLike) -> Union[float, NDArray]: :param theta: input angle :type theta: scalar or ndarray :return: angle wrapped into range :math:`[-\pi, \pi)` + :rtype: scalar or ndarray :seealso: :func:`wrap_0_2pi` :func:`wrap_0_pi` :func:`wrap_mpi2_pi2` :func:`angle_wrap` """ theta = getvector(theta) y = np.mod(theta + math.pi, 2 * math.pi) - np.pi if isinstance(y, np.ndarray) and len(y) == 1: - return float(y) + return float(y[0]) else: return y @@ -643,6 +647,7 @@ def angdiff(a, b=None): - ``angdiff(a, b)`` is the difference ``a - b`` wrapped to the range :math:`[-\pi, \pi)`. This is the operator :math:`a \circleddash b` used in the RVC book + - If ``a`` and ``b`` are both scalars, the result is scalar - If ``a`` is array_like, the result is a NumPy array ``a[i]-b`` - If ``a`` is array_like, the result is a NumPy array ``a-b[i]`` @@ -651,6 +656,7 @@ def angdiff(a, b=None): - ``angdiff(a)`` is the angle or vector of angles ``a`` wrapped to the range :math:`[-\pi, \pi)`. + - If ``a`` is a scalar, the result is scalar - If ``a`` is array_like, the result is a NumPy array @@ -671,7 +677,7 @@ def angdiff(a, b=None): y = np.mod(a + math.pi, 2 * math.pi) - math.pi if isinstance(y, np.ndarray) and len(y) == 1: - return float(y) + return float(y[0]) else: return y diff --git a/spatialmath/quaternion.py b/spatialmath/quaternion.py index f9b73873..79bdaf0c 100644 --- a/spatialmath/quaternion.py +++ b/spatialmath/quaternion.py @@ -1443,7 +1443,7 @@ def AngVec( :seealso: :meth:`UnitQuaternion.angvec` :meth:`UnitQuaternion.exp` :func:`~spatialmath.base.transforms3d.angvec2r` """ v = smb.getvector(v, 3) - theta = smb.getunit(theta, unit, dim=0) + theta = smb.getunit(theta, unit, vector=False) return cls( s=math.cos(theta / 2), v=math.sin(theta / 2) * v, norm=False, check=False ) diff --git a/tests/base/test_argcheck.py b/tests/base/test_argcheck.py index 685393b5..39c943d1 100755 --- a/tests/base/test_argcheck.py +++ b/tests/base/test_argcheck.py @@ -122,11 +122,49 @@ def test_verifymatrix(self): verifymatrix(a, (3, 4)) def test_unit(self): - self.assertIsInstance(getunit(1), np.ndarray) + # scalar -> vector + self.assertEqual(getunit(1), np.array([1])) + self.assertEqual(getunit(1, dim=0), np.array([1])) + with self.assertRaises(ValueError): + self.assertEqual(getunit(1, dim=1), np.array([1])) + + self.assertEqual(getunit(1, unit="deg"), np.array([1 * math.pi / 180.0])) + self.assertEqual(getunit(1, dim=0, unit="deg"), np.array([1 * math.pi / 180.0])) + with self.assertRaises(ValueError): + self.assertEqual( + getunit(1, dim=1, unit="deg"), np.array([1 * math.pi / 180.0]) + ) + + # scalar -> scalar + self.assertEqual(getunit(1, vector=False), 1) + self.assertEqual(getunit(1, dim=0, vector=False), 1) + with self.assertRaises(ValueError): + self.assertEqual(getunit(1, dim=1, vector=False), 1) + + self.assertIsInstance(getunit(1.0, vector=False), float) + self.assertIsInstance(getunit(1, vector=False), int) + + self.assertEqual(getunit(1, vector=False, unit="deg"), 1 * math.pi / 180.0) + self.assertEqual( + getunit(1, dim=0, vector=False, unit="deg"), 1 * math.pi / 180.0 + ) + with self.assertRaises(ValueError): + self.assertEqual( + getunit(1, dim=1, vector=False, unit="deg"), 1 * math.pi / 180.0 + ) + + self.assertIsInstance(getunit(1.0, vector=False, unit="deg"), float) + self.assertIsInstance(getunit(1, vector=False, unit="deg"), float) + + # vector -> vector + self.assertEqual(getunit([1]), np.array([1])) + self.assertEqual(getunit([1], dim=1), np.array([1])) + with self.assertRaises(ValueError): + getunit([1], dim=0) + self.assertIsInstance(getunit([1, 2]), np.ndarray) self.assertIsInstance(getunit((1, 2)), np.ndarray) self.assertIsInstance(getunit(np.r_[1, 2]), np.ndarray) - self.assertIsInstance(getunit(1.0, dim=0), float) nt.assert_equal(getunit(5, "rad"), 5) nt.assert_equal(getunit(5, "deg"), 5 * math.pi / 180.0) From d1057e56109ef37a1987b0031e593fd0428f8af6 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Fri, 31 Jan 2025 04:27:37 +1100 Subject: [PATCH 11/14] Fix the intersphinx source for matplotlib. (#156) --- docs/source/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 369c185b..b039bc85 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -187,7 +187,7 @@ intersphinx_mapping = { "numpy": ("http://docs.scipy.org/doc/numpy/", None), "scipy": ("http://docs.scipy.org/doc/scipy/reference/", None), - "matplotlib": ("http://matplotlib.sourceforge.net/", None), + "matplotlib": ("https://matplotlib.org/stable/", None), } # -------- Options favicon -------------------------------------------------------# From c04abc701d79d738826643938421d883f94a3e64 Mon Sep 17 00:00:00 2001 From: Mark Yeatman <129521731+myeatman-bdai@users.noreply.github.com> Date: Thu, 30 Jan 2025 13:12:15 -0500 Subject: [PATCH 12/14] Format everything with black. (#161) --- .pre-commit-config.yaml | 35 ++++ docs/source/conf.py | 10 +- pyproject.toml | 9 +- spatialmath/DualQuaternion.py | 1 - spatialmath/__init__.py | 4 +- spatialmath/base/animate.py | 19 ++- spatialmath/base/quaternions.py | 75 ++++++--- spatialmath/base/transforms2d.py | 12 +- spatialmath/base/transforms3d.py | 25 +-- spatialmath/baseposelist.py | 5 +- spatialmath/baseposematrix.py | 19 ++- spatialmath/pose3d.py | 33 ++-- spatialmath/quaternion.py | 15 +- spatialmath/spline.py | 22 +-- spatialmath/twist.py | 2 +- tests/base/test_numeric.py | 9 - tests/base/test_symbolic.py | 5 +- tests/base/test_transforms.py | 9 - tests/base/test_transforms2d.py | 19 +-- tests/base/test_transforms3d.py | 6 +- tests/base/test_transformsNd.py | 5 +- tests/base/test_vectors.py | 49 +++--- tests/test_baseposelist.py | 22 +-- tests/test_dualquaternion.py | 59 +++---- tests/test_pose3d.py | 8 +- tests/test_quaternion.py | 12 +- tests/test_spline.py | 45 +++-- tests/test_twist.py | 274 ++++++++++++++++--------------- 28 files changed, 437 insertions(+), 371 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..36085fe2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,35 @@ +repos: +# - repo: https://github.com/charliermarsh/ruff-pre-commit +# # Ruff version. +# rev: 'v0.1.0' +# hooks: +# - id: ruff +# args: ['--fix', '--config', 'pyproject.toml'] + +- repo: https://github.com/psf/black + rev: 'refs/tags/23.10.0:refs/tags/23.10.0' + hooks: + - id: black + language_version: python3.10 + args: ['--config', 'pyproject.toml'] + verbose: true + +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: end-of-file-fixer + - id: debug-statements # Ensure we don't commit `import pdb; pdb.set_trace()` + exclude: | + (?x)^( + docker/ros/web/static/.*| + )$ + - id: trailing-whitespace + exclude: | + (?x)^( + docker/ros/web/static/.*| + (.*/).*\.patch| + )$ +# - repo: https://github.com/pre-commit/mirrors-mypy +# rev: v1.6.1 +# hooks: +# - id: mypy diff --git a/docs/source/conf.py b/docs/source/conf.py index b039bc85..b5fcf84a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -11,8 +11,6 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -import os -import sys # sys.path.insert(0, os.path.abspath('.')) # sys.path.insert(0, os.path.abspath('..')) @@ -67,9 +65,11 @@ # choose UTF-8 encoding to allow for Unicode characters, eg. ansitable # Python session setup, turn off color printing for SE3, set NumPy precision autorun_languages = {} -autorun_languages['pycon_output_encoding'] = 'UTF-8' -autorun_languages['pycon_input_encoding'] = 'UTF-8' -autorun_languages['pycon_runfirst'] = """ +autorun_languages["pycon_output_encoding"] = "UTF-8" +autorun_languages["pycon_input_encoding"] = "UTF-8" +autorun_languages[ + "pycon_runfirst" +] = """ from spatialmath import SE3 SE3._color = False import numpy as np diff --git a/pyproject.toml b/pyproject.toml index 4295f2f3..452bf290 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ keywords = [ "SO(2)", "SE(2)", "SO(3)", "SE(3)", "twist", "product of exponential", "translation", "orientation", "angle-axis", "Lie group", "skew symmetric matrix", - "pose", "translation", "rotation matrix", + "pose", "translation", "rotation matrix", "rigid body transform", "homogeneous transformation", "Euler angles", "roll-pitch-yaw angles", "quaternion", "unit-quaternion", @@ -63,9 +63,9 @@ dev = [ docs = [ "sphinx", - "sphinx-rtd-theme", - "sphinx-autorun", - "sphinxcontrib-jsmath", + "sphinx-rtd-theme", + "sphinx-autorun", + "sphinxcontrib-jsmath", "sphinx-favicon", "sphinx-autodoc-typehints", ] @@ -88,6 +88,7 @@ packages = [ ] [tool.black] +required-version = "23.10.0" line-length = 88 target-version = ['py38'] exclude = "camera_derivatives.py" diff --git a/spatialmath/DualQuaternion.py b/spatialmath/DualQuaternion.py index 3b945d7c..f8ee0f7d 100644 --- a/spatialmath/DualQuaternion.py +++ b/spatialmath/DualQuaternion.py @@ -357,7 +357,6 @@ def SE3(self) -> SE3: if __name__ == "__main__": # pragma: no cover - from spatialmath import SE3, UnitDualQuaternion print(UnitDualQuaternion(SE3())) diff --git a/spatialmath/__init__.py b/spatialmath/__init__.py index 18cb74b4..551481e1 100644 --- a/spatialmath/__init__.py +++ b/spatialmath/__init__.py @@ -17,8 +17,6 @@ from spatialmath.DualQuaternion import DualQuaternion, UnitDualQuaternion from spatialmath.spline import BSplineSE3, InterpSplineSE3, SplineFit -# from spatialmath.Plucker import * -# from spatialmath import base as smb __all__ = [ # pose @@ -46,7 +44,7 @@ "Ellipse", "BSplineSE3", "InterpSplineSE3", - "SplineFit" + "SplineFit", ] try: diff --git a/spatialmath/base/animate.py b/spatialmath/base/animate.py index a2e31f72..1ca8baec 100755 --- a/spatialmath/base/animate.py +++ b/spatialmath/base/animate.py @@ -109,7 +109,9 @@ def __init__( if len(dim) == 2: dim = dim * 3 elif len(dim) != 6: - raise ValueError(f"dim must have 2 or 6 elements, got {dim}. See docstring for details.") + raise ValueError( + f"dim must have 2 or 6 elements, got {dim}. See docstring for details." + ) ax.set_xlim(dim[0:2]) ax.set_ylim(dim[2:4]) ax.set_zlim(dim[4:]) @@ -223,7 +225,7 @@ def update(frame, animation): else: # [unlikely] other types are converted to np array T = np.array(frame) - + if T.shape == (3, 3): T = smb.r2t(T) @@ -606,14 +608,14 @@ def trplot2( smb.trplot2(self.start, ax=self, block=False, **kwargs) def run( - self, + self, movie: Optional[str] = None, axes: Optional[plt.Axes] = None, repeat: bool = False, interval: int = 50, nframes: int = 100, - wait: bool = False, - **kwargs + wait: bool = False, + **kwargs, ): """ Run the animation @@ -663,7 +665,6 @@ def update(frame, animation): animation._draw(T) self.count += 1 # say we're still running - if movie is not None: repeat = False @@ -698,7 +699,9 @@ def update(frame, animation): print("overwriting movie", movie) else: print("creating movie", movie) - FFwriter = animation.FFMpegWriter(fps=1000 / interval, extra_args=["-vcodec", "libx264"]) + FFwriter = animation.FFMpegWriter( + fps=1000 / interval, extra_args=["-vcodec", "libx264"] + ) _ani.save(movie, writer=FFwriter) if wait: @@ -902,8 +905,6 @@ def set_ylabel(self, *args, **kwargs): # plotvol3(2) # tranimate(attitude()) - from spatialmath import base - # T = smb.rpy2r(0.3, 0.4, 0.5) # # smb.tranimate(T, wait=True) # s = smb.tranimate(T, movie=True) diff --git a/spatialmath/base/quaternions.py b/spatialmath/base/quaternions.py index d5652d4a..364a5ea8 100755 --- a/spatialmath/base/quaternions.py +++ b/spatialmath/base/quaternions.py @@ -851,39 +851,49 @@ def qslerp( def _compute_cdf_sin_squared(theta: float): """ Computes the CDF for the distribution of angular magnitude for uniformly sampled rotations. - + :arg theta: angular magnitude :rtype: float :return: cdf of a given angular magnitude :rtype: float - Helper function for uniform sampling of rotations with constrained angular magnitude. + Helper function for uniform sampling of rotations with constrained angular magnitude. This function returns the integral of the pdf of angular magnitudes (2/pi * sin^2(theta/2)). """ return (theta - np.sin(theta)) / np.pi + @lru_cache(maxsize=1) -def _generate_inv_cdf_sin_squared_interp(num_interpolation_points: int = 256) -> interpolate.interp1d: +def _generate_inv_cdf_sin_squared_interp( + num_interpolation_points: int = 256, +) -> interpolate.interp1d: """ Computes an interpolation function for the inverse CDF of the distribution of angular magnitude. - + :arg num_interpolation_points: number of points to use in the interpolation function :rtype: int :return: interpolation function for the inverse cdf of a given angular magnitude :rtype: interpolate.interp1d - Helper function for uniform sampling of rotations with constrained angular magnitude. - This function returns interpolation function for the inverse of the integral of the + Helper function for uniform sampling of rotations with constrained angular magnitude. + 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. """ 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) - return interpolate.interp1d(cdf_sin_squared_interp_values, cdf_sin_squared_interp_angles) + cdf_sin_squared_interp_values = _compute_cdf_sin_squared( + cdf_sin_squared_interp_angles + ) + return interpolate.interp1d( + cdf_sin_squared_interp_values, cdf_sin_squared_interp_angles + ) + -def _compute_inv_cdf_sin_squared(x: ArrayLike, num_interpolation_points: int = 256) -> ArrayLike: +def _compute_inv_cdf_sin_squared( + x: ArrayLike, num_interpolation_points: int = 256 +) -> ArrayLike: """ Computes the inverse CDF of the distribution of angular magnitude. - + :arg x: value for cdf of angular magnitudes :rtype: ArrayLike :arg num_interpolation_points: number of points to use in the interpolation function @@ -891,17 +901,24 @@ def _compute_inv_cdf_sin_squared(x: ArrayLike, num_interpolation_points: int = 2 :return: angular magnitude associate with cdf value :rtype: ArrayLike - Helper function for uniform sampling of rotations with constrained angular magnitude. - This function returns the angle associated with the cdf value derived form integral of + Helper function for uniform sampling of rotations with constrained angular magnitude. + This function returns the angle associated with the cdf value derived form integral of the pdf of angular magnitudes (2/pi * sin^2(theta/2)), which is not analytically defined. """ - inv_cdf_sin_squared_interp = _generate_inv_cdf_sin_squared_interp(num_interpolation_points) + inv_cdf_sin_squared_interp = _generate_inv_cdf_sin_squared_interp( + num_interpolation_points + ) return inv_cdf_sin_squared_interp(x) -def qrand(theta_range:Optional[ArrayLike2] = None, unit: str = "rad", num_interpolation_points: int = 256) -> UnitQuaternionArray: + +def qrand( + theta_range: Optional[ArrayLike2] = None, + unit: str = "rad", + num_interpolation_points: int = 256, +) -> UnitQuaternionArray: """ Random unit-quaternion - + :arg theta_range: angular magnitude range [min,max], defaults to None. :type xrange: 2-element sequence, optional :arg unit: angular units: 'rad' [default], or 'deg' @@ -913,7 +930,7 @@ def qrand(theta_range:Optional[ArrayLike2] = None, unit: str = "rad", num_interp :return: random unit-quaternion :rtype: ndarray(4) - Computes a uniformly distributed random unit-quaternion, with in a maximum + Computes a uniformly distributed random unit-quaternion, with in a maximum angular magnitude, which can be considered equivalent to a random SO(3) rotation. .. runblock:: pycon @@ -924,24 +941,30 @@ def qrand(theta_range:Optional[ArrayLike2] = None, unit: str = "rad", num_interp if theta_range is not None: theta_range = getunit(theta_range, unit) - if(theta_range[0] < 0 or theta_range[1] > np.pi or theta_range[0] > theta_range[1]): - ValueError('Invalid angular range. Must be within the range[0, pi].' - + f' Recieved {theta_range}.') + if ( + theta_range[0] < 0 + or theta_range[1] > np.pi + or theta_range[0] > theta_range[1] + ): + ValueError( + "Invalid angular range. Must be within the range[0, pi]." + + f" Recieved {theta_range}." + ) + + # Sample axis and angle independently, respecting the CDF of the + # angular magnitude under uniform sampling. - # Sample axis and angle independently, respecting the CDF of the - # angular magnitude under uniform sampling. - - # Sample angle using inverse transform sampling based on CDF + # Sample angle using inverse transform sampling based on CDF # of the angular distribution (2/pi * sin^2(theta/2)) theta = _compute_inv_cdf_sin_squared( np.random.uniform( - low=_compute_cdf_sin_squared(theta_range[0]), + low=_compute_cdf_sin_squared(theta_range[0]), high=_compute_cdf_sin_squared(theta_range[1]), ), num_interpolation_points=num_interpolation_points, ) # Sample axis uniformly using 3D normal distributed - v = np.random.randn(3) + v = np.random.randn(3) v /= np.linalg.norm(v) return np.r_[math.cos(theta / 2), (math.sin(theta / 2) * v)] @@ -953,7 +976,7 @@ def qrand(theta_range:Optional[ArrayLike2] = None, unit: str = "rad", num_interp math.sqrt(u[0]) * math.sin(2 * math.pi * u[2]), math.sqrt(u[0]) * math.cos(2 * math.pi * u[2]), ] - + def qmatrix(q: ArrayLike4) -> R4x4: """ diff --git a/spatialmath/base/transforms2d.py b/spatialmath/base/transforms2d.py index 25265fff..ac0696cd 100644 --- a/spatialmath/base/transforms2d.py +++ b/spatialmath/base/transforms2d.py @@ -746,7 +746,7 @@ def trnorm2(T: SE2Array) -> SE2Array: b = unitvec(b) # fmt: off R = np.array([ - [ b[1], b[0]], + [ b[1], b[0]], [-b[0], b[1]] ]) # fmt: on @@ -810,7 +810,7 @@ def tradjoint2(T): (R, t) = smb.tr2rt(cast(SE3Array, T)) # fmt: off return np.block([ - [R, np.c_[t[1], -t[0]].T], + [R, np.c_[t[1], -t[0]].T], [0, 0, 1] ]) # type: ignore # fmt: on @@ -853,12 +853,16 @@ def tr2jac2(T: SE2Array) -> R3x3: @overload -def trinterp2(start: Optional[SO2Array], end: SO2Array, s: float, shortest: bool = True) -> SO2Array: +def trinterp2( + start: Optional[SO2Array], end: SO2Array, s: float, shortest: bool = True +) -> SO2Array: ... @overload -def trinterp2(start: Optional[SE2Array], end: SE2Array, s: float, shortest: bool = True) -> SE2Array: +def trinterp2( + start: Optional[SE2Array], end: SE2Array, s: float, shortest: bool = True +) -> SE2Array: ... diff --git a/spatialmath/base/transforms3d.py b/spatialmath/base/transforms3d.py index 350e1d14..bc2ceb05 100644 --- a/spatialmath/base/transforms3d.py +++ b/spatialmath/base/transforms3d.py @@ -40,7 +40,6 @@ isskew, isskewa, isR, - iseye, tr2rt, Ab2M, ) @@ -1605,12 +1604,16 @@ def trnorm(T: SE3Array) -> SE3Array: @overload -def trinterp(start: Optional[SO3Array], end: SO3Array, s: float, shortest: bool = True) -> SO3Array: +def trinterp( + start: Optional[SO3Array], end: SO3Array, s: float, shortest: bool = True +) -> SO3Array: ... @overload -def trinterp(start: Optional[SE3Array], end: SE3Array, s: float, shortest: bool = True) -> SE3Array: +def trinterp( + start: Optional[SE3Array], end: SE3Array, s: float, shortest: bool = True +) -> SE3Array: ... @@ -1954,15 +1957,15 @@ def rpy2jac(angles: ArrayLike3, order: str = "zyx") -> R3x3: if order == "xyz": # fmt: off - J = np.array([ - [ sp, 0, 1], + J = np.array([ + [ sp, 0, 1], [-cp * sy, cy, 0], [ cp * cy, sy, 0] ]) # type: ignore # fmt: on elif order == "zyx": # fmt: off - J = np.array([ + J = np.array([ [ cp * cy, -sy, 0], [ cp * sy, cy, 0], [-sp, 0, 1], @@ -1970,7 +1973,7 @@ def rpy2jac(angles: ArrayLike3, order: str = "zyx") -> R3x3: # fmt: on elif order == "yxz": # fmt: off - J = np.array([ + J = np.array([ [ cp * sy, cy, 0], [-sp, 0, 1], [ cp * cy, -sy, 0] @@ -2350,7 +2353,7 @@ def rotvelxform( # analytical rates -> angular velocity # fmt: off A = np.array([ - [ S(beta), 0, 1], + [ S(beta), 0, 1], [-S(gamma)*C(beta), C(gamma), 0], # type: ignore [ C(beta)*C(gamma), S(gamma), 0] # type: ignore ]) @@ -2360,7 +2363,7 @@ def rotvelxform( # fmt: off A = np.array([ [0, -S(gamma)/C(beta), C(gamma)/C(beta)], # type: ignore - [0, C(gamma), S(gamma)], + [0, C(gamma), S(gamma)], [1, S(gamma)*T(beta), -C(gamma)*T(beta)] # type: ignore ]) # fmt: on @@ -2724,7 +2727,7 @@ def tr2adjoint(T): (R, t) = tr2rt(T) # fmt: off return np.block([ - [R, skew(t) @ R], + [R, skew(t) @ R], [Z, R] ]) # fmt: on @@ -3432,8 +3435,6 @@ def tranimate(T: Union[SO3Array, SE3Array], **kwargs) -> str: # print(angvelxform([p, q, r], representation='eul')) - import pathlib - # exec( # open( # pathlib.Path(__file__).parent.parent.parent.absolute() diff --git a/spatialmath/baseposelist.py b/spatialmath/baseposelist.py index d729b902..b102b4bb 100644 --- a/spatialmath/baseposelist.py +++ b/spatialmath/baseposelist.py @@ -667,11 +667,12 @@ def unop( else: return [op(x) for x in self.data] + if __name__ == "__main__": from spatialmath import SO3, SO2 - R = SO3([[1,0,0],[0,1,0],[0,0,1]]) + R = SO3([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) print(R.eulervec()) R = SO2([0.3, 0.4, 0.5]) - pass \ No newline at end of file + pass diff --git a/spatialmath/baseposematrix.py b/spatialmath/baseposematrix.py index 1a850600..87071df3 100644 --- a/spatialmath/baseposematrix.py +++ b/spatialmath/baseposematrix.py @@ -377,7 +377,12 @@ def log(self, twist: Optional[bool] = False) -> Union[NDArray, List[NDArray]]: else: return log - def interp(self, end: Optional[bool] = None, s: Union[int, float] = None, shortest: bool = True) -> Self: + def interp( + self, + end: Optional[bool] = None, + s: Union[int, float] = None, + shortest: bool = True, + ) -> Self: """ Interpolate between poses (superclass method) @@ -434,13 +439,19 @@ def interp(self, end: Optional[bool] = None, s: Union[int, float] = None, shorte if self.N == 2: # SO(2) or SE(2) return self.__class__( - [smb.trinterp2(start=self.A, end=end, s=_s, shortest=shortest) for _s in s] + [ + smb.trinterp2(start=self.A, end=end, s=_s, shortest=shortest) + for _s in s + ] ) elif self.N == 3: # SO(3) or SE(3) return self.__class__( - [smb.trinterp(start=self.A, end=end, s=_s, shortest=shortest) for _s in s] + [ + smb.trinterp(start=self.A, end=end, s=_s, shortest=shortest) + for _s in s + ] ) def interp1(self, s: float = None) -> Self: @@ -1692,7 +1703,7 @@ def _op2(left, right: Self, op: Callable): # pylint: disable=no-self-argument if __name__ == "__main__": - from spatialmath import SE3, SE2, SO2 + from spatialmath import SO2 C = SO2(0.5) A = np.array([[10, 0], [0, 1]]) diff --git a/spatialmath/pose3d.py b/spatialmath/pose3d.py index 3b4821e5..9324eda1 100644 --- a/spatialmath/pose3d.py +++ b/spatialmath/pose3d.py @@ -17,7 +17,7 @@ .. inheritance-diagram:: spatialmath.pose3d :top-classes: collections.UserList :parts: 1 - + .. image:: ../figs/pose-values.png """ from __future__ import annotations @@ -35,6 +35,7 @@ from spatialmath.twist import Twist3 from typing import TYPE_CHECKING, Optional + if TYPE_CHECKING: from spatialmath.quaternion import UnitQuaternion @@ -341,7 +342,7 @@ def eulervec(self) -> R3: """ theta, v = smb.tr2angvec(self.R) return theta * v - + # ------------------------------------------------------------------------ # @staticmethod @@ -455,7 +456,9 @@ def Rz(cls, theta, unit: str = "rad") -> Self: return cls([smb.rotz(x, unit=unit) for x in smb.getvector(theta)], check=False) @classmethod - def Rand(cls, N: int = 1, *, theta_range:Optional[ArrayLike2] = None, unit: str = "rad") -> Self: + def Rand( + cls, N: int = 1, *, theta_range: Optional[ArrayLike2] = None, unit: str = "rad" + ) -> Self: """ Construct a new SO(3) from random rotation @@ -481,7 +484,13 @@ def Rand(cls, N: int = 1, *, theta_range:Optional[ArrayLike2] = None, unit: str :seealso: :func:`spatialmath.quaternion.UnitQuaternion.Rand` """ - return cls([smb.q2r(smb.qrand(theta_range=theta_range, unit=unit)) for _ in range(0, N)], check=False) + return cls( + [ + smb.q2r(smb.qrand(theta_range=theta_range, unit=unit)) + for _ in range(0, N) + ], + check=False, + ) @overload @classmethod @@ -1311,11 +1320,11 @@ def yaw_SE2(self, order: str = "zyx") -> SE2: """ if len(self) == 1: if order == "zyx": - return SE2(self.x, self.y, self.rpy(order = order)[2]) + return SE2(self.x, self.y, self.rpy(order=order)[2]) elif order == "xyz": - return SE2(self.z, self.y, self.rpy(order = order)[2]) + return SE2(self.z, self.y, self.rpy(order=order)[2]) elif order == "yxz": - return SE2(self.z, self.x, self.rpy(order = order)[2]) + return SE2(self.z, self.x, self.rpy(order=order)[2]) else: return SE2([e.yaw_SE2() for e in self]) @@ -1601,7 +1610,7 @@ def Rand( xrange: Optional[ArrayLike2] = (-1, 1), yrange: Optional[ArrayLike2] = (-1, 1), zrange: Optional[ArrayLike2] = (-1, 1), - theta_range:Optional[ArrayLike2] = None, + theta_range: Optional[ArrayLike2] = None, unit: str = "rad", ) -> SE3: # pylint: disable=arguments-differ """ @@ -1825,7 +1834,7 @@ def AngleAxis( a rotation of ``θ`` about the vector ``v``. .. math:: - + \mbox{if}\,\, \theta \left\{ \begin{array}{ll} = 0 & \mbox{return identity matrix}\\ \ne 0 & \mbox{v must have a finite length} @@ -2100,11 +2109,7 @@ def Rt( return cls(smb.rt2tr(R, t, check=check), check=check) @classmethod - def CopyFrom( - cls, - T: SE3Array, - check: bool = True - ) -> SE3: + def CopyFrom(cls, T: SE3Array, check: bool = True) -> SE3: """ Create an SE(3) from a 4x4 numpy array that is passed by value. diff --git a/spatialmath/quaternion.py b/spatialmath/quaternion.py index 79bdaf0c..2994b6e6 100644 --- a/spatialmath/quaternion.py +++ b/spatialmath/quaternion.py @@ -17,7 +17,7 @@ from __future__ import annotations import math import numpy as np -from typing import Any, Type +from typing import Any import spatialmath.base as smb from spatialmath.pose3d import SO3, SE3 from spatialmath.baseposelist import BasePoseList @@ -1005,10 +1005,10 @@ def __init__( """ super().__init__() - # handle: UnitQuaternion(v)`` constructs a unit quaternion with specified elements + # handle: UnitQuaternion(v)`` constructs a unit quaternion with specified elements # from ``v`` which is a 4-vector given as a list, tuple, or ndarray(4) if s is None and smb.isvector(v, 4): - v,s = (s,v) + v, s = (s, v) if v is None: # single argument @@ -1248,7 +1248,9 @@ def Rz(cls, angles: ArrayLike, unit: Optional[str] = "rad") -> UnitQuaternion: ) @classmethod - def Rand(cls, N: int = 1, *, theta_range:Optional[ArrayLike2] = None, unit: str = "rad") -> UnitQuaternion: + def Rand( + cls, N: int = 1, *, theta_range: Optional[ArrayLike2] = None, unit: str = "rad" + ) -> UnitQuaternion: """ Construct a new random unit quaternion @@ -1275,7 +1277,10 @@ def Rand(cls, N: int = 1, *, theta_range:Optional[ArrayLike2] = None, unit: str :seealso: :meth:`UnitQuaternion.Rand` """ - return cls([smb.qrand(theta_range=theta_range, unit=unit) for i in range(0, N)], check=False) + return cls( + [smb.qrand(theta_range=theta_range, unit=unit) for i in range(0, N)], + check=False, + ) @classmethod def Eul(cls, *angles: List[float], unit: Optional[str] = "rad") -> UnitQuaternion: diff --git a/spatialmath/spline.py b/spatialmath/spline.py index 8d30bd80..7f849442 100644 --- a/spatialmath/spline.py +++ b/spatialmath/spline.py @@ -2,12 +2,11 @@ # MIT Licence, see details in top-level file: LICENCE """ -Classes for parameterizing a trajectory in SE3 with splines. +Classes for parameterizing a trajectory in SE3 with splines. """ from abc import ABC, abstractmethod -from functools import cached_property -from typing import List, Optional, Tuple, Set +from typing import List, Optional, Tuple import matplotlib.pyplot as plt import numpy as np @@ -160,11 +159,11 @@ def stochastic_downsample_interpolation( epsilon_angle: float = 1e-1, normalize_time: bool = True, bc_type: str = "not-a-knot", - check_type: str = "local" + check_type: str = "local", ) -> Tuple[InterpSplineSE3, List[int]]: """ - Uses a random dropout to downsample a trajectory with an interpolated spline. Keeps the start and - end points of the trajectory. Takes a random order of the remaining indices, and then checks the error bound + Uses a random dropout to downsample a trajectory with an interpolated spline. Keeps the start and + end points of the trajectory. Takes a random order of the remaining indices, and then checks the error bound of just that point if check_type=="local", checks the error of the whole trajectory is check_type=="global". Local is **much** faster. @@ -175,7 +174,7 @@ def stochastic_downsample_interpolation( interpolation_indices = list(range(len(self.pose_data))) - # randomly attempt to remove poses from the trajectory + # randomly attempt to remove poses from the trajectory # always keep the start and end removal_choices = interpolation_indices.copy() removal_choices.remove(0) @@ -197,14 +196,17 @@ def stochastic_downsample_interpolation( SO3(self.spline.spline_so3(sample_time).as_matrix()) ) euclidean_error = np.linalg.norm( - self.pose_data[candidate_removal_index].t - self.spline.spline_xyz(sample_time) + self.pose_data[candidate_removal_index].t + - self.spline.spline_xyz(sample_time) ) elif check_type == "global": angular_error = self.max_angular_error() euclidean_error = self.max_euclidean_error() else: - raise ValueError(f"check_type must be 'local' of 'global', is {check_type}.") - + raise ValueError( + f"check_type must be 'local' of 'global', is {check_type}." + ) + if (angular_error > epsilon_angle) or (euclidean_error > epsilon_xyz): interpolation_indices.append(candidate_removal_index) interpolation_indices.sort() diff --git a/spatialmath/twist.py b/spatialmath/twist.py index f84a0f1b..dcefa840 100644 --- a/spatialmath/twist.py +++ b/spatialmath/twist.py @@ -655,7 +655,7 @@ def RPY(cls, *pos, **kwargs): scalars. Foo bar! - + Example: .. runblock:: pycon diff --git a/tests/base/test_numeric.py b/tests/base/test_numeric.py index e6b9de50..256a3cb1 100755 --- a/tests/base/test_numeric.py +++ b/tests/base/test_numeric.py @@ -8,9 +8,7 @@ """ import numpy as np -import numpy.testing as nt import unittest -import math from spatialmath.base.numeric import * @@ -18,11 +16,9 @@ class TestNumeric(unittest.TestCase): def test_numjac(self): - pass def test_array2str(self): - x = [1.2345678] s = array2str(x) @@ -52,7 +48,6 @@ def test_array2str(self): self.assertEqual(s, "[ 1, 2, 3 | 4, 5, 6 ]") def test_bresenham(self): - x, y = bresenham((-10, -10), (20, 10)) self.assertIsInstance(x, np.ndarray) self.assertEqual(x.ndim, 1) @@ -91,7 +86,6 @@ def test_bresenham(self): self.assertTrue(all(d <= np.sqrt(2))) def test_mpq(self): - data = np.array([[-1, 1, 1, -1], [-1, -1, 1, 1]]) self.assertEqual(mpq_point(data, 0, 0), 4) @@ -99,7 +93,6 @@ def test_mpq(self): self.assertEqual(mpq_point(data, 0, 1), 0) def test_gauss1d(self): - x = np.arange(-10, 10, 0.02) y = gauss1d(2, 1, x) @@ -109,7 +102,6 @@ def test_gauss1d(self): self.assertAlmostEqual(x[m], 2) def test_gauss2d(self): - r = np.arange(-10, 10, 0.02) X, Y = np.meshgrid(r, r) Z = gauss2d([2, 3], np.eye(2), X, Y) @@ -121,5 +113,4 @@ def test_gauss2d(self): # ---------------------------------------------------------------------------------------# if __name__ == "__main__": - unittest.main() diff --git a/tests/base/test_symbolic.py b/tests/base/test_symbolic.py index 7a503b5b..cc441cc5 100644 --- a/tests/base/test_symbolic.py +++ b/tests/base/test_symbolic.py @@ -46,7 +46,6 @@ def test_issymbol(self): @unittest.skipUnless(_symbolics, "sympy required") def test_functions(self): - theta = symbol("theta") self.assertTrue(isinstance(sin(theta), sp.Expr)) self.assertTrue(isinstance(sin(1.0), float)) @@ -57,12 +56,11 @@ def test_functions(self): self.assertTrue(isinstance(sqrt(theta), sp.Expr)) self.assertTrue(isinstance(sqrt(1.0), float)) - x = (theta - 1) * (theta + 1) - theta ** 2 + x = (theta - 1) * (theta + 1) - theta**2 self.assertTrue(math.isclose(simplify(x).evalf(), -1)) @unittest.skipUnless(_symbolics, "sympy required") def test_constants(self): - x = zero() self.assertTrue(isinstance(x, sp.Expr)) self.assertTrue(math.isclose(x.evalf(), 0)) @@ -82,5 +80,4 @@ def test_constants(self): # ---------------------------------------------------------------------------------------# if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/base/test_transforms.py b/tests/base/test_transforms.py index 71b01bb3..67f3e776 100755 --- a/tests/base/test_transforms.py +++ b/tests/base/test_transforms.py @@ -12,13 +12,9 @@ import numpy.testing as nt import unittest from math import pi -import math from scipy.linalg import logm, expm from spatialmath.base import * -from spatialmath.base import sym - -import matplotlib.pyplot as plt class TestLie(unittest.TestCase): @@ -49,7 +45,6 @@ def test_skew(self): ) # check contents, vex already verified def test_vexa(self): - S = np.array([[0, -3, 1], [3, 0, 2], [0, 0, 0]]) nt.assert_array_almost_equal(vexa(S), np.array([1, 2, 3])) @@ -80,7 +75,6 @@ def test_skewa(self): ) # check contents, vexa already verified def test_trlog(self): - # %%% SO(3) tests # zero rotation case nt.assert_array_almost_equal(trlog(np.eye(3)), skew([0, 0, 0])) @@ -189,7 +183,6 @@ def test_trlog(self): # TODO def test_trexp(self): - # %% SO(3) tests # % so(3) @@ -271,7 +264,6 @@ def test_trexp(self): nt.assert_array_almost_equal(trexp(trlog(T)), T) def test_trexp2(self): - # % so(2) # zero rotation case @@ -323,5 +315,4 @@ def test_trnorm(self): # ---------------------------------------------------------------------------------------# if __name__ == "__main__": - unittest.main() diff --git a/tests/base/test_transforms2d.py b/tests/base/test_transforms2d.py index ff099930..f78e38e3 100755 --- a/tests/base/test_transforms2d.py +++ b/tests/base/test_transforms2d.py @@ -12,7 +12,7 @@ import unittest from math import pi import math -from scipy.linalg import logm, expm +from scipy.linalg import logm import pytest import sys @@ -27,7 +27,6 @@ skewa, homtrans, ) -from spatialmath.base.numeric import numjac import matplotlib.pyplot as plt @@ -125,22 +124,14 @@ def test_pos2tr2(self): nt.assert_array_almost_equal( transl2([1, 2]), np.array([[1, 0, 1], [0, 1, 2], [0, 0, 1]]) ) - nt.assert_array_almost_equal( - tr2pos2(pos2tr2(1, 2)), np.array([1, 2]) - ) + nt.assert_array_almost_equal(tr2pos2(pos2tr2(1, 2)), np.array([1, 2])) def test_tr2jac2(self): T = trot2(0.3, t=[4, 5]) jac2 = tr2jac2(T) - nt.assert_array_almost_equal( - jac2[:2, :2], smb.t2r(T) - ) - nt.assert_array_almost_equal( - jac2[:3, 2], np.array([0, 0, 1]) - ) - nt.assert_array_almost_equal( - jac2[2, :3], np.array([0, 0, 1]) - ) + nt.assert_array_almost_equal(jac2[:2, :2], smb.t2r(T)) + nt.assert_array_almost_equal(jac2[:3, 2], np.array([0, 0, 1])) + nt.assert_array_almost_equal(jac2[2, :3], np.array([0, 0, 1])) def test_xyt2tr(self): T = xyt2tr([1, 2, 0]) diff --git a/tests/base/test_transforms3d.py b/tests/base/test_transforms3d.py index 2f1e6049..8b2fb080 100755 --- a/tests/base/test_transforms3d.py +++ b/tests/base/test_transforms3d.py @@ -12,8 +12,7 @@ import numpy.testing as nt import unittest from math import pi -import math -from scipy.linalg import logm, expm +from scipy.linalg import logm from spatialmath.base.transforms3d import * from spatialmath.base.transformsNd import isR, t2r, r2t, rt2tr, skew @@ -518,7 +517,7 @@ def test_tr2angvec(self): nt.assert_array_almost_equal(v, np.r_[0, 1, 0]) true_ang = 1.51 - true_vec = np.array([0., 1., 0.]) + true_vec = np.array([0.0, 1.0, 0.0]) eps = 1e-08 # show that tr2angvec works on true rotation matrix @@ -806,6 +805,7 @@ def test_x2tr(self): x2tr(x, representation="exp"), transl(t) @ r2t(trexp(gamma)) ) + # ---------------------------------------------------------------------------------------# if __name__ == "__main__": unittest.main() diff --git a/tests/base/test_transformsNd.py b/tests/base/test_transformsNd.py index 92d9e2a3..14c7fd42 100755 --- a/tests/base/test_transformsNd.py +++ b/tests/base/test_transformsNd.py @@ -11,8 +11,6 @@ import numpy.testing as nt import unittest from math import pi -import math -from scipy.linalg import logm, expm from spatialmath.base.transformsNd import * from spatialmath.base.transforms3d import trotx, transl, rotx, isrot, ishom @@ -25,7 +23,6 @@ from spatialmath.base.symbolic import symbol except ImportError: _symbolics = False -import matplotlib.pyplot as plt class TestND(unittest.TestCase): @@ -58,7 +55,7 @@ def test_r2t(self): with self.assertRaises(ValueError): r2t(np.eye(3, 4)) - + _ = r2t(np.ones((3, 3)), check=False) with self.assertRaises(ValueError): r2t(np.ones((3, 3)), check=True) diff --git a/tests/base/test_vectors.py b/tests/base/test_vectors.py index 592c2d16..15c6a451 100755 --- a/tests/base/test_vectors.py +++ b/tests/base/test_vectors.py @@ -12,7 +12,6 @@ import unittest from math import pi import math -from scipy.linalg import logm, expm from spatialmath.base.vectors import * @@ -218,18 +217,10 @@ def test_unittwist_norm(self): self.assertIsNone(a[1]) def test_unittwist2(self): - nt.assert_array_almost_equal( - unittwist2([1, 0, 0]), np.r_[1, 0, 0] - ) - nt.assert_array_almost_equal( - unittwist2([0, 2, 0]), np.r_[0, 1, 0] - ) - nt.assert_array_almost_equal( - unittwist2([0, 0, -3]), np.r_[0, 0, -1] - ) - nt.assert_array_almost_equal( - unittwist2([2, 0, -2]), np.r_[1, 0, -1] - ) + nt.assert_array_almost_equal(unittwist2([1, 0, 0]), np.r_[1, 0, 0]) + nt.assert_array_almost_equal(unittwist2([0, 2, 0]), np.r_[0, 1, 0]) + nt.assert_array_almost_equal(unittwist2([0, 0, -3]), np.r_[0, 0, -1]) + nt.assert_array_almost_equal(unittwist2([2, 0, -2]), np.r_[1, 0, -1]) self.assertIsNone(unittwist2([0, 0, 0])) @@ -329,14 +320,30 @@ def test_wrap(self): theta = angle_factor * pi self.assertAlmostEqual(angle_wrap(theta), wrap_mpi_pi(theta)) self.assertAlmostEqual(angle_wrap(-theta), wrap_mpi_pi(-theta)) - self.assertAlmostEqual(angle_wrap(theta=theta, mode="-pi:pi"), wrap_mpi_pi(theta)) - self.assertAlmostEqual(angle_wrap(theta=-theta, mode="-pi:pi"), wrap_mpi_pi(-theta)) - self.assertAlmostEqual(angle_wrap(theta=theta, mode="0:2pi"), wrap_0_2pi(theta)) - self.assertAlmostEqual(angle_wrap(theta=-theta, mode="0:2pi"), wrap_0_2pi(-theta)) - self.assertAlmostEqual(angle_wrap(theta=theta, mode="0:pi"), wrap_0_pi(theta)) - self.assertAlmostEqual(angle_wrap(theta=-theta, mode="0:pi"), wrap_0_pi(-theta)) - self.assertAlmostEqual(angle_wrap(theta=theta, mode="-pi/2:pi/2"), wrap_mpi2_pi2(theta)) - self.assertAlmostEqual(angle_wrap(theta=-theta, mode="-pi/2:pi/2"), wrap_mpi2_pi2(-theta)) + self.assertAlmostEqual( + angle_wrap(theta=theta, mode="-pi:pi"), wrap_mpi_pi(theta) + ) + self.assertAlmostEqual( + angle_wrap(theta=-theta, mode="-pi:pi"), wrap_mpi_pi(-theta) + ) + self.assertAlmostEqual( + angle_wrap(theta=theta, mode="0:2pi"), wrap_0_2pi(theta) + ) + self.assertAlmostEqual( + angle_wrap(theta=-theta, mode="0:2pi"), wrap_0_2pi(-theta) + ) + self.assertAlmostEqual( + angle_wrap(theta=theta, mode="0:pi"), wrap_0_pi(theta) + ) + self.assertAlmostEqual( + angle_wrap(theta=-theta, mode="0:pi"), wrap_0_pi(-theta) + ) + self.assertAlmostEqual( + angle_wrap(theta=theta, mode="-pi/2:pi/2"), wrap_mpi2_pi2(theta) + ) + self.assertAlmostEqual( + angle_wrap(theta=-theta, mode="-pi/2:pi/2"), wrap_mpi2_pi2(-theta) + ) with self.assertRaises(ValueError): angle_wrap(theta=theta, mode="foo") diff --git a/tests/test_baseposelist.py b/tests/test_baseposelist.py index 30da5681..c3f9b311 100644 --- a/tests/test_baseposelist.py +++ b/tests/test_baseposelist.py @@ -2,28 +2,28 @@ import numpy as np from spatialmath.baseposelist import BasePoseList + # create a subclass to test with, its value is a scalar class X(BasePoseList): def __init__(self, value=0, check=False): super().__init__() self.data = [value] - + @staticmethod def _identity(): return 0 @property def shape(self): - return (1,1) + return (1, 1) @staticmethod def isvalid(x): return True -class TestBasePoseList(unittest.TestCase): +class TestBasePoseList(unittest.TestCase): def test_constructor(self): - x = X() self.assertIsInstance(x, X) self.assertEqual(len(x), 1) @@ -43,13 +43,13 @@ def test_setget(self): for i in range(0, 10): x[i] = X(2 * i) - for i,v in enumerate(x): + for i, v in enumerate(x): self.assertEqual(v.A, 2 * i) def test_append(self): x = X.Empty() for i in range(0, 10): - x.append(X(i+1)) + x.append(X(i + 1)) self.assertEqual(len(x), 10) self.assertEqual([xx.A for xx in x], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) @@ -63,7 +63,7 @@ def test_extend(self): x.extend(y) self.assertEqual(len(x), 10) self.assertEqual([xx.A for xx in x], [1, 2, 3, 4, 5, 10, 11, 12, 13, 14]) - + def test_insert(self): x = X.Alloc(10) for i in range(0, 10): @@ -134,13 +134,13 @@ def test_unop(self): self.assertEqual(x.unop(f), [2, 4, 6, 8, 10]) y = x.unop(f, matrix=True) - self.assertEqual(y.shape, (5,1)) + self.assertEqual(y.shape, (5, 1)) self.assertTrue(np.all(y - np.c_[2, 4, 6, 8, 10].T == 0)) def test_arghandler(self): pass + # ---------------------------------------------------------------------------------------# -if __name__ == '__main__': - - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dualquaternion.py b/tests/test_dualquaternion.py index 39c5fc03..ed785313 100644 --- a/tests/test_dualquaternion.py +++ b/tests/test_dualquaternion.py @@ -1,4 +1,3 @@ -import math from math import pi import numpy as np @@ -6,7 +5,6 @@ import unittest from spatialmath import DualQuaternion, UnitDualQuaternion, Quaternion, SE3 -from spatialmath import base def qcompare(x, y): @@ -20,32 +18,29 @@ def qcompare(x, y): y = y.A nt.assert_array_almost_equal(x, y) -class TestDualQuaternion(unittest.TestCase): +class TestDualQuaternion(unittest.TestCase): def test_init(self): + dq = DualQuaternion(Quaternion([1.0, 2, 3, 4]), Quaternion([5.0, 6, 7, 8])) + nt.assert_array_almost_equal(dq.vec, np.r_[1, 2, 3, 4, 5, 6, 7, 8]) - dq = DualQuaternion(Quaternion([1.,2,3,4]), Quaternion([5.,6,7,8])) - nt.assert_array_almost_equal(dq.vec, np.r_[1,2,3,4,5,6,7,8]) - - dq = DualQuaternion([1.,2,3,4,5,6,7,8]) - nt.assert_array_almost_equal(dq.vec, np.r_[1,2,3,4,5,6,7,8]) - dq = DualQuaternion(np.r_[1,2,3,4,5,6,7,8]) - nt.assert_array_almost_equal(dq.vec, np.r_[1,2,3,4,5,6,7,8]) + dq = DualQuaternion([1.0, 2, 3, 4, 5, 6, 7, 8]) + nt.assert_array_almost_equal(dq.vec, np.r_[1, 2, 3, 4, 5, 6, 7, 8]) + dq = DualQuaternion(np.r_[1, 2, 3, 4, 5, 6, 7, 8]) + nt.assert_array_almost_equal(dq.vec, np.r_[1, 2, 3, 4, 5, 6, 7, 8]) def test_pure(self): - - dq = DualQuaternion.Pure([1.,2,3]) - nt.assert_array_almost_equal(dq.vec, np.r_[1,0,0,0, 0,1,2,3]) + dq = DualQuaternion.Pure([1.0, 2, 3]) + nt.assert_array_almost_equal(dq.vec, np.r_[1, 0, 0, 0, 0, 1, 2, 3]) def test_strings(self): - - dq = DualQuaternion(Quaternion([1.,2,3,4]), Quaternion([5.,6,7,8])) + dq = DualQuaternion(Quaternion([1.0, 2, 3, 4]), Quaternion([5.0, 6, 7, 8])) self.assertIsInstance(str(dq), str) self.assertIsInstance(repr(dq), str) def test_conj(self): - dq = DualQuaternion(Quaternion([1.,2,3,4]), Quaternion([5.,6,7,8])) - nt.assert_array_almost_equal(dq.conj().vec, np.r_[1,-2,-3,-4, 5,-6,-7,-8]) + dq = DualQuaternion(Quaternion([1.0, 2, 3, 4]), Quaternion([5.0, 6, 7, 8])) + nt.assert_array_almost_equal(dq.conj().vec, np.r_[1, -2, -3, -4, 5, -6, -7, -8]) # def test_norm(self): # q1 = Quaternion([1.,2,3,4]) @@ -55,26 +50,25 @@ def test_conj(self): # nt.assert_array_almost_equal(dq.norm(), (q1.norm(), q2.norm())) def test_plus(self): - dq = DualQuaternion(Quaternion([1.,2,3,4]), Quaternion([5.,6,7,8])) + dq = DualQuaternion(Quaternion([1.0, 2, 3, 4]), Quaternion([5.0, 6, 7, 8])) s = dq + dq - nt.assert_array_almost_equal(s.vec, 2*np.r_[1,2,3,4,5,6,7,8]) + nt.assert_array_almost_equal(s.vec, 2 * np.r_[1, 2, 3, 4, 5, 6, 7, 8]) def test_minus(self): - dq = DualQuaternion(Quaternion([1.,2,3,4]), Quaternion([5.,6,7,8])) + dq = DualQuaternion(Quaternion([1.0, 2, 3, 4]), Quaternion([5.0, 6, 7, 8])) s = dq - dq nt.assert_array_almost_equal(s.vec, np.zeros((8,))) def test_matrix(self): - - dq1 = DualQuaternion(Quaternion([1.,2,3,4]), Quaternion([5.,6,7,8])) + dq1 = DualQuaternion(Quaternion([1.0, 2, 3, 4]), Quaternion([5.0, 6, 7, 8])) M = dq1.matrix() self.assertIsInstance(M, np.ndarray) - self.assertEqual(M.shape, (8,8)) + self.assertEqual(M.shape, (8, 8)) def test_multiply(self): - dq1 = DualQuaternion(Quaternion([1.,2,3,4]), Quaternion([5.,6,7,8])) - dq2 = DualQuaternion(Quaternion([4,3,2,1]), Quaternion([5,6,7,8])) + dq1 = DualQuaternion(Quaternion([1.0, 2, 3, 4]), Quaternion([5.0, 6, 7, 8])) + dq2 = DualQuaternion(Quaternion([4, 3, 2, 1]), Quaternion([5, 6, 7, 8])) M = dq1.matrix() v = dq2.vec @@ -85,21 +79,19 @@ def test_unit(self): class TestUnitDualQuaternion(unittest.TestCase): - def test_init(self): - - T = SE3.Rx(pi/4) + T = SE3.Rx(pi / 4) dq = UnitDualQuaternion(T) nt.assert_array_almost_equal(dq.SE3().A, T.A) def test_norm(self): - T = SE3.Rx(pi/4) + T = SE3.Rx(pi / 4) dq = UnitDualQuaternion(T) - nt.assert_array_almost_equal(dq.norm(), (1,0)) + nt.assert_array_almost_equal(dq.norm(), (1, 0)) def test_multiply(self): - T1 = SE3.Rx(pi/4) - T2 = SE3.Rz(-pi/3) + T1 = SE3.Rx(pi / 4) + T2 = SE3.Rz(-pi / 3) T = T1 * T2 @@ -111,6 +103,5 @@ def test_multiply(self): # ---------------------------------------------------------------------------------------# -if __name__ == '__main__': # pragma: no cover - +if __name__ == "__main__": # pragma: no cover unittest.main() diff --git a/tests/test_pose3d.py b/tests/test_pose3d.py index 58396441..70b33ce0 100755 --- a/tests/test_pose3d.py +++ b/tests/test_pose3d.py @@ -242,8 +242,8 @@ def test_constructor_TwoVec(self): nt.assert_almost_equal(R.R[:, 0], v3 / np.linalg.norm(v3), 5) def test_conversion(self): - R = SO3.AngleAxis(0.7, [1,2,3]) - q = UnitQuaternion([11,7,3,-6]) + R = SO3.AngleAxis(0.7, [1, 2, 3]) + q = UnitQuaternion([11, 7, 3, -6]) R_from_q = SO3(q.R) q_from_R = UnitQuaternion(R) @@ -716,7 +716,6 @@ def test_functions_lie(self): nt.assert_equal(R, SO3.EulerVec(R.eulervec())) np.testing.assert_equal((R.inv() * R).eulervec(), np.zeros(3)) - def test_rotatedvector(self): v1 = [1, 2, 3] R = SO3.Eul(0.3, 0.4, 0.5) @@ -759,7 +758,6 @@ def test_mean(self): array_compare(m, SO3.RPY(0.1, 0.2, 0.3)) - # ============================== SE3 =====================================# @@ -872,7 +870,7 @@ def test_constructor(self): nt.assert_equal(len(R), 1) self.assertIsInstance(R, SE3) - # random + # random T = SE3.Rand() R = T.R t = T.t diff --git a/tests/test_quaternion.py b/tests/test_quaternion.py index 403b3c8d..75d31b7c 100644 --- a/tests/test_quaternion.py +++ b/tests/test_quaternion.py @@ -48,20 +48,18 @@ def test_constructor_variants(self): nt.assert_array_almost_equal( UnitQuaternion.Rz(-90, "deg").vec, np.r_[1, 0, 0, -1] / math.sqrt(2) ) - + np.random.seed(73) q = UnitQuaternion.Rand(theta_range=(0.1, 0.7)) self.assertIsInstance(q, UnitQuaternion) self.assertLessEqual(q.angvec()[0], 0.7) self.assertGreaterEqual(q.angvec()[0], 0.1) - q = UnitQuaternion.Rand(theta_range=(0.1, 0.7)) self.assertIsInstance(q, UnitQuaternion) self.assertLessEqual(q.angvec()[0], 0.7) self.assertGreaterEqual(q.angvec()[0], 0.1) - def test_constructor(self): qcompare(UnitQuaternion(), [1, 0, 0, 0]) @@ -83,8 +81,8 @@ def test_constructor(self): qcompare(UnitQuaternion(2, [0, 0, 0]), np.r_[1, 0, 0, 0]) qcompare(UnitQuaternion(-2, [0, 0, 0]), np.r_[1, 0, 0, 0]) - qcompare(UnitQuaternion([1, 2, 3, 4]), UnitQuaternion(v = [1, 2, 3, 4])) - qcompare(UnitQuaternion(s = 1, v = [2, 3, 4]), UnitQuaternion(v = [1, 2, 3, 4])) + qcompare(UnitQuaternion([1, 2, 3, 4]), UnitQuaternion(v=[1, 2, 3, 4])) + qcompare(UnitQuaternion(s=1, v=[2, 3, 4]), UnitQuaternion(v=[1, 2, 3, 4])) # from R @@ -824,8 +822,8 @@ def test_constructor(self): nt.assert_array_almost_equal(Quaternion(2, [0, 0, 0]).vec, [2, 0, 0, 0]) nt.assert_array_almost_equal(Quaternion(-2, [0, 0, 0]).vec, [-2, 0, 0, 0]) - qcompare(Quaternion([1, 2, 3, 4]), Quaternion(v = [1, 2, 3, 4])) - qcompare(Quaternion(s = 1, v = [2, 3, 4]), Quaternion(v = [1, 2, 3, 4])) + qcompare(Quaternion([1, 2, 3, 4]), Quaternion(v=[1, 2, 3, 4])) + qcompare(Quaternion(s=1, v=[2, 3, 4]), Quaternion(v=[1, 2, 3, 4])) # pure v = [5, 6, 7] diff --git a/tests/test_spline.py b/tests/test_spline.py index 361bc28f..9f27c608 100644 --- a/tests/test_spline.py +++ b/tests/test_spline.py @@ -27,7 +27,10 @@ def test_evaluation(self): def test_visualize(self): spline = BSplineSE3(self.control_poses) - spline.visualize(sample_times= np.linspace(0, 1.0, 100), animate=True, repeat=False) + spline.visualize( + sample_times=np.linspace(0, 1.0, 100), animate=True, repeat=False + ) + class TestInterpSplineSE3: waypoints = [ @@ -56,14 +59,20 @@ def test_evaluation(self): for time, pose in zip(norm_time, self.waypoints): nt.assert_almost_equal(spline(time).angdist(pose), 0.0) nt.assert_almost_equal(np.linalg.norm(spline(time).t - pose.t), 0.0) - + def test_small_delta_t(self): - InterpSplineSE3(np.linspace(0, InterpSplineSE3._e, len(self.waypoints)), self.waypoints) + InterpSplineSE3( + np.linspace(0, InterpSplineSE3._e, len(self.waypoints)), self.waypoints + ) def test_visualize(self): spline = InterpSplineSE3(self.times, self.waypoints) - spline.visualize(sample_times= np.linspace(0, self.time_horizon, 100), animate=True, repeat=False) - + spline.visualize( + sample_times=np.linspace(0, self.time_horizon, 100), + animate=True, + repeat=False, + ) + class TestSplineFit: num_data_points = 300 @@ -74,7 +83,11 @@ class TestSplineFit: timestamps = np.linspace(0, 1, num_data_points) trajectory = [ SE3.Rt( - t=[t * 0.4, 0.4 * np.sin(t * 2 * np.pi * 0.5), 0.4 * np.cos(t * 2 * np.pi * 0.5)], + t=[ + t * 0.4, + 0.4 * np.sin(t * 2 * np.pi * 0.5), + 0.4 * np.cos(t * 2 * np.pi * 0.5), + ], R=SO3.Rx(t * 2 * np.pi * 0.5), ) for t in timestamps * time_horizon @@ -85,11 +98,15 @@ def test_spline_fit(self): spline, kept_indices = fit.stochastic_downsample_interpolation() fraction_points_removed = 1.0 - len(kept_indices) / self.num_data_points - - assert(fraction_points_removed > 0.2) - assert(len(spline.control_poses)==len(kept_indices)) - assert(len(spline.timepoints)==len(kept_indices)) - - assert( fit.max_angular_error() < np.deg2rad(5.0) ) - assert( fit.max_angular_error() < 0.1 ) - spline.visualize(sample_times= np.linspace(0, self.time_horizon, 100), animate=True, repeat=False) \ No newline at end of file + + assert fraction_points_removed > 0.2 + assert len(spline.control_poses) == len(kept_indices) + assert len(spline.timepoints) == len(kept_indices) + + assert fit.max_angular_error() < np.deg2rad(5.0) + assert fit.max_angular_error() < 0.1 + spline.visualize( + sample_times=np.linspace(0, self.time_horizon, 100), + animate=True, + repeat=False, + ) diff --git a/tests/test_twist.py b/tests/test_twist.py index 12660c7d..70f237a8 100755 --- a/tests/test_twist.py +++ b/tests/test_twist.py @@ -1,5 +1,4 @@ import numpy.testing as nt -import matplotlib.pyplot as plt import unittest """ @@ -7,12 +6,14 @@ """ from math import pi from spatialmath.twist import * + # from spatialmath import super_pose # as sp from spatialmath.base import * from spatialmath.baseposematrix import BasePoseMatrix from spatialmath import SE2, SE3 from spatialmath.twist import BaseTwist + def array_compare(x, y): if isinstance(x, BasePoseMatrix): x = x.A @@ -26,9 +27,7 @@ def array_compare(x, y): class Twist3dTest(unittest.TestCase): - def test_constructor(self): - s = [1, 2, 3, 4, 5, 6] x = Twist3(s) self.assertIsInstance(x, Twist3) @@ -36,29 +35,28 @@ def test_constructor(self): array_compare(x.v, [1, 2, 3]) array_compare(x.w, [4, 5, 6]) array_compare(x.S, s) - - x = Twist3([1,2,3], [4,5,6]) + + x = Twist3([1, 2, 3], [4, 5, 6]) array_compare(x.v, [1, 2, 3]) array_compare(x.w, [4, 5, 6]) array_compare(x.S, s) y = Twist3(x) array_compare(x, y) - + x = Twist3(SE3()) - array_compare(x, [0,0,0,0,0,0]) - - + array_compare(x, [0, 0, 0, 0, 0, 0]) + def test_list(self): x = Twist3([1, 0, 0, 0, 0, 0]) y = Twist3([1, 0, 0, 0, 0, 0]) - + a = Twist3(x) a.append(y) self.assertEqual(len(a), 2) array_compare(a[0], x) array_compare(a[1], y) - + def test_conversion_SE3(self): T = SE3.Rx(0) tw = Twist3(T) @@ -68,134 +66,145 @@ def test_conversion_SE3(self): T = SE3.Rx(0) * SE3(1, 2, 3) array_compare(Twist3(T).SE3(), T) - + def test_conversion_se3(self): s = [1, 2, 3, 4, 5, 6] x = Twist3(s) - - array_compare(x.skewa(), np.array([[ 0., -6., 5., 1.], - [ 6., 0., -4., 2.], - [-5., 4., 0., 3.], - [ 0., 0., 0., 0.]])) - + + array_compare( + x.skewa(), + np.array( + [ + [0.0, -6.0, 5.0, 1.0], + [6.0, 0.0, -4.0, 2.0], + [-5.0, 4.0, 0.0, 3.0], + [0.0, 0.0, 0.0, 0.0], + ] + ), + ) + def test_conversion_Plucker(self): pass - + def test_list_constuctor(self): x = Twist3([1, 0, 0, 0, 0, 0]) - - a = Twist3([x,x,x,x]) + + a = Twist3([x, x, x, x]) self.assertIsInstance(a, Twist3) self.assertEqual(len(a), 4) - + a = Twist3([x.skewa(), x.skewa(), x.skewa(), x.skewa()]) self.assertIsInstance(a, Twist3) self.assertEqual(len(a), 4) - + a = Twist3([x.S, x.S, x.S, x.S]) self.assertIsInstance(a, Twist3) self.assertEqual(len(a), 4) - + s = np.r_[1, 2, 3, 4, 5, 6] a = Twist3([s, s, s, s]) self.assertIsInstance(a, Twist3) self.assertEqual(len(a), 4) - + def test_predicate(self): x = Twist3.UnitRevolute([1, 2, 3], [0, 0, 0]) self.assertFalse(x.isprismatic) - + # check prismatic twist x = Twist3.UnitPrismatic([1, 2, 3]) self.assertTrue(x.isprismatic) - + self.assertTrue(Twist3.isvalid(x.skewa())) self.assertTrue(Twist3.isvalid(x.S)) - + self.assertFalse(Twist3.isvalid(2)) self.assertFalse(Twist3.isvalid(np.eye(4))) - + def test_str(self): x = Twist3([1, 2, 3, 4, 5, 6]) s = str(x) self.assertIsInstance(s, str) self.assertEqual(len(s), 14) - self.assertEqual(s.count('\n'), 0) - + self.assertEqual(s.count("\n"), 0) + x.append(x) s = str(x) self.assertIsInstance(s, str) self.assertEqual(len(s), 29) - self.assertEqual(s.count('\n'), 1) - + self.assertEqual(s.count("\n"), 1) + def test_variant_constructors(self): - # check rotational twist x = Twist3.UnitRevolute([1, 2, 3], [0, 0, 0]) array_compare(x, np.r_[0, 0, 0, unitvec([1, 2, 3])]) - + # check prismatic twist x = Twist3.UnitPrismatic([1, 2, 3]) - array_compare(x, np.r_[unitvec([1, 2, 3]), 0, 0, 0, ]) - + array_compare( + x, + np.r_[ + unitvec([1, 2, 3]), + 0, + 0, + 0, + ], + ) + def test_SE3_twists(self): - tw = Twist3( SE3.Rx(0) ) - array_compare(tw, np.r_[0, 0, 0, 0, 0, 0]) - - tw = Twist3( SE3.Rx(pi / 2) ) - array_compare(tw, np.r_[0, 0, 0, pi / 2, 0, 0]) - - tw = Twist3( SE3.Ry(pi / 2) ) - array_compare(tw, np.r_[0, 0, 0, 0, pi / 2, 0]) - - tw = Twist3( SE3.Rz(pi / 2) ) - array_compare(tw, np.r_[0, 0, 0, 0, 0, pi / 2]) - - tw = Twist3( SE3([1, 2, 3]) ) - array_compare(tw, [1, 2, 3, 0, 0, 0]) - - tw = Twist3( SE3([1, 2, 3]) * SE3.Ry(pi / 2)) - array_compare(tw, np.r_[-pi / 2, 2, pi, 0, pi / 2, 0]) - + tw = Twist3(SE3.Rx(0)) + array_compare(tw, np.r_[0, 0, 0, 0, 0, 0]) + + tw = Twist3(SE3.Rx(pi / 2)) + array_compare(tw, np.r_[0, 0, 0, pi / 2, 0, 0]) + + tw = Twist3(SE3.Ry(pi / 2)) + array_compare(tw, np.r_[0, 0, 0, 0, pi / 2, 0]) + + tw = Twist3(SE3.Rz(pi / 2)) + array_compare(tw, np.r_[0, 0, 0, 0, 0, pi / 2]) + + tw = Twist3(SE3([1, 2, 3])) + array_compare(tw, [1, 2, 3, 0, 0, 0]) + + tw = Twist3(SE3([1, 2, 3]) * SE3.Ry(pi / 2)) + array_compare(tw, np.r_[-pi / 2, 2, pi, 0, pi / 2, 0]) + def test_exp(self): tw = Twist3.UnitRevolute([1, 0, 0], [0, 0, 0]) - array_compare(tw.exp(pi/2), SE3.Rx(pi/2)) - + array_compare(tw.exp(pi / 2), SE3.Rx(pi / 2)) + tw = Twist3.UnitRevolute([0, 1, 0], [0, 0, 0]) - array_compare(tw.exp(pi/2), SE3.Ry(pi/2)) - + array_compare(tw.exp(pi / 2), SE3.Ry(pi / 2)) + tw = Twist3.UnitRevolute([0, 0, 1], [0, 0, 0]) - array_compare(tw.exp(pi/2), SE3.Rz(pi / 2)) - + array_compare(tw.exp(pi / 2), SE3.Rz(pi / 2)) + def test_arith(self): - # check overloaded * T1 = SE3(1, 2, 3) * SE3.Rx(pi / 2) T2 = SE3(4, 5, -6) * SE3.Ry(-pi / 2) - + x1 = Twist3(T1) x2 = Twist3(T2) - array_compare( (x1 * x2).exp(), T1 * T2) - array_compare( (x2 * x1).exp(), T2 * T1) - + array_compare((x1 * x2).exp(), T1 * T2) + array_compare((x2 * x1).exp(), T2 * T1) + def test_prod(self): # check prod T1 = SE3(1, 2, 3) * SE3.Rx(pi / 2) T2 = SE3(4, 5, -6) * SE3.Ry(-pi / 2) - + x1 = Twist3(T1) x2 = Twist3(T2) - + x = Twist3([x1, x2]) - array_compare( x.prod().SE3(), T1 * T2) - + array_compare(x.prod().SE3(), T1 * T2) + class Twist2dTest(unittest.TestCase): - def test_constructor(self): - s = [1, 2, 3] x = Twist2(s) self.assertIsInstance(x, Twist2) @@ -203,33 +212,32 @@ def test_constructor(self): array_compare(x.v, [1, 2]) array_compare(x.w, [3]) array_compare(x.S, s) - - x = Twist2([1,2], 3) + + x = Twist2([1, 2], 3) array_compare(x.v, [1, 2]) array_compare(x.w, [3]) array_compare(x.S, s) y = Twist2(x) array_compare(x, y) - + # construct from SE2 x = Twist2(SE2()) - array_compare(x, [0,0,0]) - - x = Twist2( SE2(0, 0, pi / 2)) + array_compare(x, [0, 0, 0]) + + x = Twist2(SE2(0, 0, pi / 2)) array_compare(x, np.r_[0, 0, pi / 2]) - - x = Twist2( SE2(1, 2,0 )) + + x = Twist2(SE2(1, 2, 0)) array_compare(x, np.r_[1, 2, 0]) - - x = Twist2( SE2(1, 2, pi / 2)) + + x = Twist2(SE2(1, 2, pi / 2)) array_compare(x, np.r_[3 * pi / 4, pi / 4, pi / 2]) - - + def test_list(self): x = Twist2([1, 0, 0]) y = Twist2([1, 0, 0]) - + a = Twist2(x) a.append(y) self.assertEqual(len(a), 2) @@ -237,132 +245,126 @@ def test_list(self): array_compare(a[1], y) def test_variant_constructors(self): - # check rotational twist x = Twist2.UnitRevolute([1, 2]) array_compare(x, np.r_[2, -1, 1]) - + # check prismatic twist x = Twist2.UnitPrismatic([1, 2]) array_compare(x, np.r_[unitvec([1, 2]), 0]) - + def test_conversion_SE2(self): T = SE2(1, 2, 0.3) tw = Twist2(T) array_compare(tw.SE2(), T) self.assertIsInstance(tw.SE2(), SE2) self.assertEqual(len(tw.SE2()), 1) - + def test_conversion_se2(self): s = [1, 2, 3] x = Twist2(s) - - array_compare(x.skewa(), np.array([[ 0., -3., 1.], - [ 3., 0., 2.], - [ 0., 0., 0.]])) + + array_compare( + x.skewa(), np.array([[0.0, -3.0, 1.0], [3.0, 0.0, 2.0], [0.0, 0.0, 0.0]]) + ) def test_list_constuctor(self): x = Twist2([1, 0, 0]) - - a = Twist2([x,x,x,x]) + + a = Twist2([x, x, x, x]) self.assertIsInstance(a, Twist2) self.assertEqual(len(a), 4) - + a = Twist2([x.skewa(), x.skewa(), x.skewa(), x.skewa()]) self.assertIsInstance(a, Twist2) self.assertEqual(len(a), 4) - + a = Twist2([x.S, x.S, x.S, x.S]) self.assertIsInstance(a, Twist2) self.assertEqual(len(a), 4) - + s = np.r_[1, 2, 3] a = Twist2([s, s, s, s]) self.assertIsInstance(a, Twist2) self.assertEqual(len(a), 4) - + def test_predicate(self): x = Twist2.UnitRevolute([1, 2]) self.assertFalse(x.isprismatic) - + # check prismatic twist x = Twist2.UnitPrismatic([1, 2]) self.assertTrue(x.isprismatic) - + self.assertTrue(Twist2.isvalid(x.skewa())) self.assertTrue(Twist2.isvalid(x.S)) - + self.assertFalse(Twist2.isvalid(2)) self.assertFalse(Twist2.isvalid(np.eye(3))) - + def test_str(self): x = Twist2([1, 2, 3]) s = str(x) self.assertIsInstance(s, str) self.assertEqual(len(s), 8) - self.assertEqual(s.count('\n'), 0) - + self.assertEqual(s.count("\n"), 0) + x.append(x) s = str(x) self.assertIsInstance(s, str) self.assertEqual(len(s), 17) - self.assertEqual(s.count('\n'), 1) - + self.assertEqual(s.count("\n"), 1) def test_SE2_twists(self): - tw = Twist2( SE2() ) + tw = Twist2(SE2()) array_compare(tw, np.r_[0, 0, 0]) - - tw = Twist2( SE2(0, 0, pi / 2) ) + + tw = Twist2(SE2(0, 0, pi / 2)) array_compare(tw, np.r_[0, 0, pi / 2]) - - - tw = Twist2( SE2([1, 2, 0]) ) + + tw = Twist2(SE2([1, 2, 0])) array_compare(tw, [1, 2, 0]) - - tw = Twist2( SE2([1, 2, pi / 2])) - array_compare(tw, np.r_[ 3 * pi / 4, pi / 4, pi / 2]) - + + tw = Twist2(SE2([1, 2, pi / 2])) + array_compare(tw, np.r_[3 * pi / 4, pi / 4, pi / 2]) + def test_exp(self): x = Twist2.UnitRevolute([0, 0]) - array_compare(x.exp(pi/2), SE2(0, 0, pi/2)) - + array_compare(x.exp(pi / 2), SE2(0, 0, pi / 2)) + x = Twist2.UnitRevolute([1, 0]) - array_compare(x.exp(pi/2), SE2(1, -1, pi/2)) - + array_compare(x.exp(pi / 2), SE2(1, -1, pi / 2)) + x = Twist2.UnitRevolute([1, 2]) - array_compare(x.exp(pi/2), SE2(3, 1, pi/2)) + array_compare(x.exp(pi / 2), SE2(3, 1, pi / 2)) - def test_arith(self): - # check overloaded * T1 = SE2(1, 2, pi / 2) T2 = SE2(4, 5, -pi / 4) - + x1 = Twist2(T1) x2 = Twist2(T2) - array_compare( (x1 * x2).exp(), (T1 * T2).A) - array_compare( (x2 * x1).exp(), (T2 * T1).A) + array_compare((x1 * x2).exp(), (T1 * T2).A) + array_compare((x2 * x1).exp(), (T2 * T1).A) + + array_compare((x1 * x2).SE2(), (T1 * T2).A) + array_compare((x2 * x1).SE2(), (T2 * T1)) - array_compare( (x1 * x2).SE2(), (T1 * T2).A) - array_compare( (x2 * x1).SE2(), (T2 * T1)) - def test_prod(self): # check prod T1 = SE2(1, 2, pi / 2) T2 = SE2(4, 5, -pi / 4) - + x1 = Twist2(T1) x2 = Twist2(T2) - - x = Twist2([x1, x2]) - array_compare( x.prod().SE2(), T1 * T2) -# ---------------------------------------------------------------------------------------# -if __name__ == '__main__': + x = Twist2([x1, x2]) + array_compare(x.prod().SE2(), T1 * T2) +# ---------------------------------------------------------------------------------------# +if __name__ == "__main__": unittest.main() From 9c4a77bdc3b9f224da697911e271ca4e029b64cd Mon Sep 17 00:00:00 2001 From: Mark Yeatman <129521731+myeatman-bdai@users.noreply.github.com> Date: Fri, 31 Jan 2025 10:11:16 -0500 Subject: [PATCH 13/14] Fix trailing whitespace and end of file lines. (#164) --- .github/CONTRIBUTORS.md | 2 +- .pre-commit-config.yaml | 15 +++++----- Makefile | 1 - README.md | 50 ++++++++++++++++---------------- spatialmath/base/README.md | 27 +++++++---------- spatialmath/base/argcheck.py | 2 +- spatialmath/base/symbolic.py | 2 +- spatialmath/base/transformsNd.py | 2 +- spatialmath/geom3d.py | 4 +-- spatialmath/spatialvector.py | 2 +- 10 files changed, 49 insertions(+), 58 deletions(-) diff --git a/.github/CONTRIBUTORS.md b/.github/CONTRIBUTORS.md index 30162fb8..80467523 100644 --- a/.github/CONTRIBUTORS.md +++ b/.github/CONTRIBUTORS.md @@ -3,4 +3,4 @@ A number of people have contributed to this, and earlier, versions of this Toolb * Jesse Haviland, 2020 (part of the ropy project) * Luis Fernando Lara Tobar, 2008 * Josh Carrigg Hodson, Aditya Dua, Chee Ho Chan, 2017 (part of the robopy project) -* Peter Corke \ No newline at end of file +* Peter Corke diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 36085fe2..4dc48cd6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,17 +18,16 @@ repos: rev: v4.5.0 hooks: - id: end-of-file-fixer - - id: debug-statements # Ensure we don't commit `import pdb; pdb.set_trace()` exclude: | - (?x)^( - docker/ros/web/static/.*| - )$ + (?x)( + ^docs/ + ) + - id: debug-statements # Ensure we don't commit `import pdb; pdb.set_trace()` - id: trailing-whitespace exclude: | - (?x)^( - docker/ros/web/static/.*| - (.*/).*\.patch| - )$ + (?x)( + ^docs/ + ) # - repo: https://github.com/pre-commit/mirrors-mypy # rev: v1.6.1 # hooks: diff --git a/Makefile b/Makefile index c129744d..ad24ab91 100644 --- a/Makefile +++ b/Makefile @@ -39,4 +39,3 @@ clean: .FORCE (cd docs; make clean) -rm -r *.egg-info -rm -r dist build - diff --git a/README.md b/README.md index a2db78e4..738395e7 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ space: | ------------ | ---------------- | -------- | | pose | ``SE3`` ``Twist3`` ``UnitDualQuaternion`` | ``SE2`` ``Twist2`` | | orientation | ``SO3`` ``UnitQuaternion`` | ``SO2`` | - - + + More specifically: * `SE3` matrices belonging to the group $\mathbf{SE}(3)$ for position and orientation (pose) in 3-dimensions @@ -160,26 +160,26 @@ For example, to create an object representing a rotation of 0.3 radians about th >>> from spatialmath import SO3, SE3 >>> R1 = SO3.Rx(0.3) >>> R1 - 1 0 0 - 0 0.955336 -0.29552 - 0 0.29552 0.955336 + 1 0 0 + 0 0.955336 -0.29552 + 0 0.29552 0.955336 ``` while a rotation of 30 deg about the z-axis is ```python >>> R2 = SO3.Rz(30, 'deg') >>> R2 - 0.866025 -0.5 0 - 0.5 0.866025 0 - 0 0 1 + 0.866025 -0.5 0 + 0.5 0.866025 0 + 0 0 1 ``` -and the composition of these two rotations is +and the composition of these two rotations is ```python >>> R = R1 * R2 - 0.866025 -0.5 0 - 0.433013 0.75 -0.5 - 0.25 0.433013 0.866025 + 0.866025 -0.5 0 + 0.433013 0.75 -0.5 + 0.25 0.433013 0.866025 ``` We can find the corresponding Euler angles (in radians) @@ -198,16 +198,16 @@ Frequently in robotics we want a sequence, a trajectory, of rotation matrices or >>> len(R) 3 >>> R[1] - 1 0 0 - 0 0.955336 -0.29552 - 0 0.29552 0.955336 + 1 0 0 + 0 0.955336 -0.29552 + 0 0.29552 0.955336 ``` and this can be used in `for` loops and list comprehensions. An alternative way of constructing this would be (`R1`, `R2` defined above) ```python ->>> R = SO3( [ SO3(), R1, R2 ] ) +>>> R = SO3( [ SO3(), R1, R2 ] ) >>> len(R) 3 ``` @@ -233,7 +233,7 @@ will produce a result where each element is the product of each element of the l Similarly ```python ->>> A = SO3.Ry(0.5) * R +>>> A = SO3.Ry(0.5) * R >>> len(R) 32 ``` @@ -242,7 +242,7 @@ will produce a result where each element is the product of the left-hand side wi Finally ```python ->>> A = R * R +>>> A = R * R >>> len(R) 32 ``` @@ -267,10 +267,10 @@ We can print and plot these objects as well ``` >>> T = SE3(1,2,3) * SE3.Rx(30, 'deg') >>> T.print() - 1 0 0 1 - 0 0.866025 -0.5 2 - 0 0.5 0.866025 3 - 0 0 0 1 + 1 0 0 1 + 0 0.866025 -0.5 2 + 0 0.5 0.866025 3 + 0 0 0 1 >>> T.printline() t = 1, 2, 3; rpy/zyx = 30, 0, 0 deg @@ -339,7 +339,7 @@ array([[1., 0., 1.], [0., 0., 1.]]) transl2( (1,2) ) -Out[444]: +Out[444]: array([[1., 0., 1.], [0., 1., 2.], [0., 0., 1.]]) @@ -349,7 +349,7 @@ array([[1., 0., 1.], ``` transl2( np.array([1,2]) ) -Out[445]: +Out[445]: array([[1., 0., 1.], [0., 1., 2.], [0., 0., 1.]]) @@ -436,7 +436,7 @@ Out[259]: int a = T[1,1] a -Out[256]: +Out[256]: cos(theta) type(a) Out[255]: cos diff --git a/spatialmath/base/README.md b/spatialmath/base/README.md index a4003ffc..baa3595a 100644 --- a/spatialmath/base/README.md +++ b/spatialmath/base/README.md @@ -21,9 +21,9 @@ import spatialmath as sm R = sm.SO3.Rx(30, 'deg') print(R) - 1 0 0 - 0 0.866025 -0.5 - 0 0.5 0.866025 + 1 0 0 + 0 0.866025 -0.5 + 0 0.5 0.866025 ``` which constructs a rotation about the x-axis by 30 degrees. @@ -45,7 +45,7 @@ array([[ 1. , 0. , 0. ], [ 0. , 0.29552021, 0.95533649]]) >>> rotx(30, unit='deg') -Out[438]: +Out[438]: array([[ 1. , 0. , 0. ], [ 0. , 0.8660254, -0.5 ], [ 0. , 0.5 , 0.8660254]]) @@ -64,7 +64,7 @@ We also support multiple ways of passing vector information to functions that re ``` transl2(1, 2) -Out[442]: +Out[442]: array([[1., 0., 1.], [0., 1., 2.], [0., 0., 1.]]) @@ -74,13 +74,13 @@ array([[1., 0., 1.], ``` transl2( [1,2] ) -Out[443]: +Out[443]: array([[1., 0., 1.], [0., 1., 2.], [0., 0., 1.]]) transl2( (1,2) ) -Out[444]: +Out[444]: array([[1., 0., 1.], [0., 1., 2.], [0., 0., 1.]]) @@ -90,7 +90,7 @@ array([[1., 0., 1.], ``` transl2( np.array([1,2]) ) -Out[445]: +Out[445]: array([[1., 0., 1.], [0., 1., 2.], [0., 0., 1.]]) @@ -129,7 +129,7 @@ Using classes ensures type safety, for example it stops us mixing a 2D homogeneo These classes are all derived from two parent classes: * `RTBPose` which provides common functionality for all -* `UserList` which provdides the ability to act like a list +* `UserList` which provdides the ability to act like a list The latter is important because frequnetly in robotics we want a sequence, a trajectory, of rotation matrices or poses. However a list of these items has the type `list` and the elements are not enforced to be homogeneous, ie. a list could contain a mixture of classes. @@ -178,7 +178,7 @@ Out[259]: int a = T[1,1] a -Out[256]: +Out[256]: cos(theta) type(a) Out[255]: cos @@ -226,10 +226,3 @@ TypeError: can't convert expression to float | t2r | yes | | rotx | yes | | rotx | yes | - - - - - - - diff --git a/spatialmath/base/argcheck.py b/spatialmath/base/argcheck.py index 38b5eb1a..9db91817 100644 --- a/spatialmath/base/argcheck.py +++ b/spatialmath/base/argcheck.py @@ -5,7 +5,7 @@ """ Utility functions for testing and converting passed arguments. Used in all -spatialmath functions and classes to provides for flexibility in argument types +spatialmath functions and classes to provides for flexibility in argument types that can be passed. """ diff --git a/spatialmath/base/symbolic.py b/spatialmath/base/symbolic.py index 2d92f4d4..a95aec4a 100644 --- a/spatialmath/base/symbolic.py +++ b/spatialmath/base/symbolic.py @@ -8,7 +8,7 @@ Symbolic arguments. If SymPy is not installed then only the standard numeric operations are -supported. +supported. """ import math diff --git a/spatialmath/base/transformsNd.py b/spatialmath/base/transformsNd.py index c04e5d8b..611c89a3 100644 --- a/spatialmath/base/transformsNd.py +++ b/spatialmath/base/transformsNd.py @@ -514,7 +514,7 @@ def skew(v): if len(v) == 1: # fmt: off return np.array([ - [0.0, -v[0]], + [0.0, -v[0]], [v[0], 0.0] ]) # type: ignore # fmt: on diff --git a/spatialmath/geom3d.py b/spatialmath/geom3d.py index 8b191ebd..896192dc 100755 --- a/spatialmath/geom3d.py +++ b/spatialmath/geom3d.py @@ -507,7 +507,7 @@ def vec(self) -> R6: def skew(self) -> R4x4: r""" Line as a Plucker skew-symmetric matrix - + :return: Skew-symmetric matrix form of Plucker coordinates :rtype: ndarray(4,4) @@ -523,7 +523,7 @@ def skew(self) -> R4x4: -\omega_x & -\omega_y & -\omega_z & 0 \end{bmatrix} .. note:: - + - For two homogeneous points P and Q on the line, :math:`PQ^T-QP^T` is also skew symmetric. - The projection of Plucker line by a perspective camera is a diff --git a/spatialmath/spatialvector.py b/spatialmath/spatialvector.py index f839e359..0f996bee 100644 --- a/spatialmath/spatialvector.py +++ b/spatialmath/spatialvector.py @@ -10,7 +10,7 @@ :top-classes: collections.UserList :parts: 1 -.. note:: Compared to Featherstone's papers these spatial vectors have the +.. note:: Compared to Featherstone's papers these spatial vectors have the translational components first, followed by rotational components. """ From 4c68fa923bc90047a0d79a2eab5c5a84b6cee7b7 Mon Sep 17 00:00:00 2001 From: Mark Yeatman <129521731+myeatman-bdai@users.noreply.github.com> Date: Fri, 31 Jan 2025 10:11:29 -0500 Subject: [PATCH 14/14] Bump version to 1.1.14. (#163) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 452bf290..54fc237c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "spatialmath-python" -version = "1.1.13" +version = "1.1.14" authors = [ { name="Peter Corke", email="rvc@petercorke.com" }, ]