Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions spatialmath/quaternion.py
Original file line number Diff line number Diff line change
Expand Up @@ -1544,13 +1544,17 @@ def AngVec(
>>> print(UQ.AngVec(0, [1,0,0]))
>>> print(UQ.AngVec(90, [1,0,0], unit='deg'))

.. note:: :math:`\theta = 0` the result in an identity quaternion, otherwise
``V`` must have a finite length, ie. :math:`|V| > 0`.
.. note:: If :math:`\theta` is zero within the default tolerance of
:func:`~spatialmath.base.vectors.iszero`, the result is an identity
quaternion. Otherwise, ``v`` must have a nonzero norm.

:seealso: :meth:`UnitQuaternion.angvec` :meth:`UnitQuaternion.exp` :func:`~spatialmath.base.transforms3d.angvec2r`
"""
v = smb.getvector(v, 3)
theta = smb.getunit(theta, unit, vector=False)
if smb.iszero(theta):
return cls()
v = smb.unitvec(v)
return cls(
s=math.cos(theta / 2), v=math.sin(theta / 2) * v, norm=False, check=False
)
Expand Down
82 changes: 82 additions & 0 deletions tests/test_angvec_nonunit_axis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Angle-axis construction preserves unit-quaternion and zero-angle contracts."""

import math
import unittest

import numpy as np
from numpy.testing import assert_allclose

from spatialmath import UnitQuaternion
from spatialmath.base import angvec2r


class TestAngVecNonunitAxis(unittest.TestCase):
def test_unit_norm(self):
assert_allclose(np.linalg.norm(UnitQuaternion.AngVec(0.5, [1, 2, 3]).vec), 1)

def test_angle_round_trip(self):
theta, axis = UnitQuaternion.AngVec(0.5, [1, 2, 3]).angvec()
assert_allclose(theta, 0.5)
assert_allclose(axis, np.array([1, 2, 3]) / np.sqrt(14))

def test_rotation_matrix(self):
assert_allclose(
UnitQuaternion.AngVec(0.5, [1, 2, 3]).R,
angvec2r(0.5, [1, 2, 3]),
atol=1e-12,
)

def test_degrees(self):
assert_allclose(
UnitQuaternion.AngVec(90, [0, 0, 4], unit="deg").R,
angvec2r(math.pi / 2, [0, 0, 1]),
atol=1e-12,
)

def test_unit_axis_control(self):
assert_allclose(
UnitQuaternion.AngVec(0.5, [1, 0, 0]).vec,
[math.cos(0.25), math.sin(0.25), 0, 0],
)

def test_zero_angle_zero_axis(self):
assert_allclose(UnitQuaternion.AngVec(0, [0, 0, 0]).vec, [1, 0, 0, 0])

def test_zero_degrees_zero_axis(self):
assert_allclose(
UnitQuaternion.AngVec(0, [0, 0, 0], unit="deg").vec, [1, 0, 0, 0]
)

def test_nonzero_angle_requires_nonzero_axis(self):
with self.assertRaises(ValueError):
UnitQuaternion.AngVec(0.5, [0, 0, 0])

def test_near_zero_angle_zero_axis(self):
for theta in [1e-20, -1e-20]:
with self.subTest(theta=theta):
assert_allclose(
UnitQuaternion.AngVec(theta, [0, 0, 0]).vec, [1, 0, 0, 0]
)

def test_near_zero_degrees_zero_axis(self):
for theta in [1e-20, -1e-20]:
with self.subTest(theta=theta):
assert_allclose(
UnitQuaternion.AngVec(theta, [0, 0, 0], unit="deg").vec,
[1, 0, 0, 0],
)

def test_nonzero_small_angle_requires_nonzero_axis(self):
for theta in [1e-10, -1e-10]:
with self.subTest(theta=theta):
with self.assertRaises(ValueError):
UnitQuaternion.AngVec(theta, [0, 0, 0])

def test_nonzero_small_angle_preserves_rotation(self):
for theta in [1e-10, -1e-10]:
with self.subTest(theta=theta):
assert_allclose(
UnitQuaternion.AngVec(theta, [2, 0, 0]).vec,
[math.cos(theta / 2), math.sin(theta / 2), 0, 0],
atol=0,
)
Loading