Skip to content

Commit 81071a3

Browse files
authored
Normalize the axis in UnitQuaternion.AngVec (#266)
`UnitQuaternion.AngVec(0.5, [1, 2, 3])` currently produces a quaternion with norm greater than one and a matrix that is not the requested rotation. Normalize the axis before constructing a nonzero-angle quaternion, while returning the identity for angles considered zero by `smb.iszero()` after unit conversion. Away from that zero-angle tolerance, a zero axis raises `ValueError`. The base functions (`angvec2r`, `angvec2tr` and `SO3.AngleAxis`) currently return the identity for a zero axis, even for a nonzero angle; this PR does not change them. The earlier description incorrectly claimed consistency with those base constructors. Their alignment is separate, as discussed in review. Regression coverage includes norm, angle/axis round-trip, matrix agreement, degrees, unit-axis controls, exact and near-zero angles in both units, and positive/negative small angles outside the tolerance. These controls preserve nonzero rotation with a valid axis and reject a zero axis outside the tolerance. Validation on the designated Linux workstation, Python 3.11: - Original normalization regressions against unchanged master: 5 failed, 3 passed; original fix: 8 passed. - Follow-up tests against the previous PR head `54fb3c8`: four near-zero subtest failures; the four other new control subtests passed. - Final focused tests: 12 passed, 8 subtests passed. - Full suite: 414 passed, 11 skipped, 63 subtests passed (15 warnings). - Project-pinned Black 23.10.0 and whitespace checks passed. All execution and formatting ran on the remote workstation. AI assistance: an AI coding agent helped investigate, implement, test, and review this change. No independent human review is claimed.
1 parent 51af998 commit 81071a3

2 files changed

Lines changed: 88 additions & 2 deletions

File tree

‎spatialmath/quaternion.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,13 +1544,17 @@ def AngVec(
15441544
>>> print(UQ.AngVec(0, [1,0,0]))
15451545
>>> print(UQ.AngVec(90, [1,0,0], unit='deg'))
15461546
1547-
.. note:: :math:`\theta = 0` the result in an identity quaternion, otherwise
1548-
``V`` must have a finite length, ie. :math:`|V| > 0`.
1547+
.. note:: If :math:`\theta` is zero within the default tolerance of
1548+
:func:`~spatialmath.base.vectors.iszero`, the result is an identity
1549+
quaternion. Otherwise, ``v`` must have a nonzero norm.
15491550
15501551
:seealso: :meth:`UnitQuaternion.angvec` :meth:`UnitQuaternion.exp` :func:`~spatialmath.base.transforms3d.angvec2r`
15511552
"""
15521553
v = smb.getvector(v, 3)
15531554
theta = smb.getunit(theta, unit, vector=False)
1555+
if smb.iszero(theta):
1556+
return cls()
1557+
v = smb.unitvec(v)
15541558
return cls(
15551559
s=math.cos(theta / 2), v=math.sin(theta / 2) * v, norm=False, check=False
15561560
)

‎tests/test_angvec_nonunit_axis.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Angle-axis construction preserves unit-quaternion and zero-angle contracts."""
2+
3+
import math
4+
import unittest
5+
6+
import numpy as np
7+
from numpy.testing import assert_allclose
8+
9+
from spatialmath import UnitQuaternion
10+
from spatialmath.base import angvec2r
11+
12+
13+
class TestAngVecNonunitAxis(unittest.TestCase):
14+
def test_unit_norm(self):
15+
assert_allclose(np.linalg.norm(UnitQuaternion.AngVec(0.5, [1, 2, 3]).vec), 1)
16+
17+
def test_angle_round_trip(self):
18+
theta, axis = UnitQuaternion.AngVec(0.5, [1, 2, 3]).angvec()
19+
assert_allclose(theta, 0.5)
20+
assert_allclose(axis, np.array([1, 2, 3]) / np.sqrt(14))
21+
22+
def test_rotation_matrix(self):
23+
assert_allclose(
24+
UnitQuaternion.AngVec(0.5, [1, 2, 3]).R,
25+
angvec2r(0.5, [1, 2, 3]),
26+
atol=1e-12,
27+
)
28+
29+
def test_degrees(self):
30+
assert_allclose(
31+
UnitQuaternion.AngVec(90, [0, 0, 4], unit="deg").R,
32+
angvec2r(math.pi / 2, [0, 0, 1]),
33+
atol=1e-12,
34+
)
35+
36+
def test_unit_axis_control(self):
37+
assert_allclose(
38+
UnitQuaternion.AngVec(0.5, [1, 0, 0]).vec,
39+
[math.cos(0.25), math.sin(0.25), 0, 0],
40+
)
41+
42+
def test_zero_angle_zero_axis(self):
43+
assert_allclose(UnitQuaternion.AngVec(0, [0, 0, 0]).vec, [1, 0, 0, 0])
44+
45+
def test_zero_degrees_zero_axis(self):
46+
assert_allclose(
47+
UnitQuaternion.AngVec(0, [0, 0, 0], unit="deg").vec, [1, 0, 0, 0]
48+
)
49+
50+
def test_nonzero_angle_requires_nonzero_axis(self):
51+
with self.assertRaises(ValueError):
52+
UnitQuaternion.AngVec(0.5, [0, 0, 0])
53+
54+
def test_near_zero_angle_zero_axis(self):
55+
for theta in [1e-20, -1e-20]:
56+
with self.subTest(theta=theta):
57+
assert_allclose(
58+
UnitQuaternion.AngVec(theta, [0, 0, 0]).vec, [1, 0, 0, 0]
59+
)
60+
61+
def test_near_zero_degrees_zero_axis(self):
62+
for theta in [1e-20, -1e-20]:
63+
with self.subTest(theta=theta):
64+
assert_allclose(
65+
UnitQuaternion.AngVec(theta, [0, 0, 0], unit="deg").vec,
66+
[1, 0, 0, 0],
67+
)
68+
69+
def test_nonzero_small_angle_requires_nonzero_axis(self):
70+
for theta in [1e-10, -1e-10]:
71+
with self.subTest(theta=theta):
72+
with self.assertRaises(ValueError):
73+
UnitQuaternion.AngVec(theta, [0, 0, 0])
74+
75+
def test_nonzero_small_angle_preserves_rotation(self):
76+
for theta in [1e-10, -1e-10]:
77+
with self.subTest(theta=theta):
78+
assert_allclose(
79+
UnitQuaternion.AngVec(theta, [2, 0, 0]).vec,
80+
[math.cos(theta / 2), math.sin(theta / 2), 0, 0],
81+
atol=0,
82+
)

0 commit comments

Comments
 (0)