Repository: glotzerlab/rowan

Issue:
to_euler failing with some quaternions
It looks like `to_euler` may be failing in some cases with particularly clean orientations; I'd guess that some tolerances may need to be added to the calculation, at a glance. For example,

```rowan.to_euler((-.5, .5, -.5, .5), convention='xyz', axis_type='intrinsic')```

produces the following rotation matrix inside the function:

```
array([[ 0.,  0.,  1.],
       [-1.,  0.,  0.],
       [ 0., -1.,  0.]])
```

which causes both sets of arguments for `arctan2` to compute `alpha` and `gamma` to be (0, 0), causing the resulting set of angles to be `(0, pi/2, 0)`.

Comments:

Patch:
diff --git a/doc/conf.py b/doc/conf.py
index d1a5f01..564930e 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -55,7 +55,7 @@ master_doc = 'index'
 
 # General information about the project.
 project = 'rowan'
-copyright = '2010-2019, Regents of the University of Michigan'
+copyright = '2010-2019, The Regents of the University of Michigan'
 author = 'Vyas Ramasubramani'
 
 # The version info for the project you're documenting, acts as replacement for
diff --git a/rowan/functions.py b/rowan/functions.py
index d97afc4..f466e35 100644
--- a/rowan/functions.py
+++ b/rowan/functions.py
@@ -697,7 +697,7 @@ def to_euler(q, convention='zyx', axis_type='intrinsic'):
         R_y(\theta)  =& \left(\begin{array}{ccc}
                             \cos \theta  & 0 & \sin \theta \\
                             0            & 1 &  0          \\
-                            -\sin \theta & 1 & \cos \theta \\
+                            -\sin \theta & 0 & \cos \theta \\
                          \end{array}\right)\\
         R_z(\theta)  =& \left(\begin{array}{ccc}
                             \cos \theta & -\sin \theta & 0 \\
@@ -722,6 +722,21 @@ def to_euler(q, convention='zyx', axis_type='intrinsic'):
     `Euler angles <https://en.wikipedia.org/wiki/Euler_angles>`_
     (specifically the section on converting between representations).
 
+    .. warning::
+
+        Euler angles are a highly problematic representation for a number of
+        reasons, not least of which is the large number of possible conventions
+        and their relative imprecision when compared to using quaternions (or
+        axis-angle representations). If possible, you should avoid Euler angles
+        and work with quaternions instead. If Euler angles are required, note
+        that they are susceptible to `gimbal lock
+        <https://en.wikipedia.org/wiki/Gimbal_lock>`_, which leads to ambiguity
+        in the representation of a given rotation. To address this issue, in
+        cases where gimbal lock arises, :func:`~.to_euler` adopts the
+        convention that :math:`\gamma=0` and represents the rotation entirely
+        in terms of :math:`\beta` and :math:`\alpha`.
+
+
     Args:
         q ((...,4) np.array): Quaternions to transform.
         convention (str): One of the 6 valid conventions zxz,
@@ -746,128 +761,182 @@ def to_euler(q, convention='zyx', axis_type='intrinsic'):
     """
     q = np.asarray(q)
     _validate_unit(q)
+    atol = 1e-3
 
     try:
-        mats = to_matrix(q)
+        # Due to minor numerical imprecision, the to_matrix function could
+        # generate a (very slightly) nonorthogonal matrix (e.g. with a norm of
+        # 1 + 2e-8). That is sufficient to throw off the trigonometric
+        # functions, so it's worthwhile to explicitly clip for safety,
+        # especially since we've already checked the quaternion norm.
+        mats = np.clip(to_matrix(q), -1, 1)
     except ValueError:
         raise ValueError(
             "Not all quaternions in q are unit quaternions.")
 
-    if axis_type == 'intrinsic':
-        # Have to hardcode the different possibilities.
-        # Classical Euler angles
-        if convention == 'xzx':
-            alpha = np.arctan2(mats[..., 2, 0], mats[..., 1, 0])
-            beta = np.arccos(mats[..., 0, 0])
-            gamma = np.arctan2(mats[..., 0, 2], -mats[..., 0, 1])
-        elif convention == 'xyx':
-            alpha = np.arctan2(mats[..., 1, 0], -mats[..., 2, 0])
-            beta = np.arccos(mats[..., 0, 0])
-            gamma = np.arctan2(mats[..., 0, 1], mats[..., 0, 2])
-        elif convention == 'yxy':
-            alpha = np.arctan2(mats[..., 0, 1], mats[..., 2, 1])
-            beta = np.arccos(mats[..., 1, 1])
-            gamma = np.arctan2(mats[..., 1, 0], -mats[..., 1, 2])
-        elif convention == 'yzy':
-            alpha = np.arctan2(mats[..., 2, 1], -mats[..., 0, 1])
-            beta = np.arccos(mats[..., 1, 1])
-            gamma = np.arctan2(mats[..., 1, 2], mats[..., 1, 0])
-        elif convention == 'zyz':
-            alpha = np.arctan2(mats[..., 1, 2], mats[..., 0, 2])
-            beta = np.arccos(mats[..., 2, 2])
-            gamma = np.arctan2(mats[..., 2, 1], -mats[..., 2, 0])
-        elif convention == 'zxz':
-            alpha = np.arctan2(mats[..., 0, 2], -mats[..., 1, 2])
-            beta = np.arccos(mats[..., 2, 2])
-            gamma = np.arctan2(mats[..., 2, 0], mats[..., 2, 1])
-        # Tait-Bryan angles
-        elif convention == 'xzy':
-            alpha = np.arctan2(mats[..., 2, 1], mats[..., 1, 1])
-            beta = np.arcsin(-mats[..., 0, 1])
-            gamma = np.arctan2(mats[..., 0, 2], mats[..., 0, 0])
-        elif convention == 'xyz':
-            alpha = np.arctan2(-mats[..., 1, 2], mats[..., 2, 2])
-            beta = np.arcsin(mats[..., 0, 2])
-            gamma = np.arctan2(-mats[..., 0, 1], mats[..., 0, 0])
-        elif convention == 'yxz':
-            alpha = np.arctan2(mats[..., 0, 2], mats[..., 2, 2])
-            beta = np.arcsin(-mats[..., 1, 2])
-            gamma = np.arctan2(mats[..., 1, 0], mats[..., 1, 1])
-        elif convention == 'yzx':
-            alpha = np.arctan2(-mats[..., 2, 0], mats[..., 0, 0])
-            beta = np.arcsin(mats[..., 1, 0])
-            gamma = np.arctan2(-mats[..., 1, 2], mats[..., 1, 1])
-        elif convention == 'zyx':
-            alpha = np.arctan2(mats[..., 1, 0], mats[..., 0, 0])
-            beta = np.arcsin(-mats[..., 2, 0])
-            gamma = np.arctan2(mats[..., 2, 1], mats[..., 2, 2])
-        elif convention == 'zxy':
-            alpha = np.arctan2(-mats[..., 0, 1], mats[..., 1, 1])
-            beta = np.arcsin(mats[..., 2, 1])
-            gamma = np.arctan2(-mats[..., 2, 0], mats[..., 2, 2])
-        else:
-            raise ValueError("Unknown convention selected!")
-    elif axis_type == 'extrinsic':
-        # For these, the matrix must be constructed in reverse order
-        # e.g. Z(\alpha)Y'(\beta)Z''(\gamma) (where primes denote the
-        # rotated frames) becomes the extrinsic rotation
-        # Z(\gamma)Y(\beta)Z(\alpha).
-
-        # Classical Euler angles
-        if convention == 'xzx':
-            alpha = np.arctan2(mats[..., 0, 2], -mats[..., 0, 1])
-            beta = np.arccos(mats[..., 0, 0])
-            gamma = np.arctan2(mats[..., 2, 0], mats[..., 1, 0])
-        elif convention == 'xyx':
-            alpha = np.arctan2(mats[..., 0, 1], mats[..., 0, 2])
-            beta = np.arccos(mats[..., 0, 0])
-            gamma = np.arctan2(mats[..., 1, 0], -mats[..., 2, 0])
-        elif convention == 'yxy':
-            alpha = np.arctan2(mats[..., 1, 0], -mats[..., 1, 2])
-            beta = np.arccos(mats[..., 1, 1])
-            gamma = np.arctan2(mats[..., 0, 1], mats[..., 2, 1])
-        elif convention == 'yzy':
-            alpha = np.arctan2(mats[..., 1, 2], mats[..., 1, 0])
-            beta = np.arccos(mats[..., 1, 1])
-            gamma = np.arctan2(mats[..., 2, 1], -mats[..., 0, 1])
-        elif convention == 'zyz':
-            alpha = np.arctan2(mats[..., 2, 1], -mats[..., 2, 0])
-            beta = np.arccos(mats[..., 2, 2])
-            gamma = np.arctan2(mats[..., 1, 2], mats[..., 0, 2])
-        elif convention == 'zxz':
-            alpha = np.arctan2(mats[..., 2, 0], mats[..., 2, 1])
-            beta = np.arccos(mats[..., 2, 2])
-            gamma = np.arctan2(mats[..., 0, 2], -mats[..., 1, 2])
-        # Tait-Bryan angles
-        elif convention == 'xzy':
-            alpha = np.arctan2(-mats[..., 1, 2], mats[..., 1, 1])
-            beta = np.arcsin(mats[..., 1, 0])
-            gamma = np.arctan2(-mats[..., 2, 0], mats[..., 0, 0])
-        elif convention == 'xyz':
-            alpha = np.arctan2(mats[..., 2, 1], mats[..., 2, 2])
-            beta = np.arcsin(-mats[..., 2, 0])
-            gamma = np.arctan2(mats[..., 1, 0], mats[..., 0, 0])
-        elif convention == 'yxz':
-            alpha = np.arctan2(-mats[..., 2, 0], mats[..., 2, 2])
-            beta = np.arcsin(mats[..., 2, 1])
-            gamma = np.arctan2(-mats[..., 0, 1], mats[..., 1, 1])
-        elif convention == 'yzx':
-            alpha = np.arctan2(mats[..., 0, 2], mats[..., 0, 0])
-            beta = np.arcsin(-mats[..., 0, 1])
-            gamma = np.arctan2(mats[..., 2, 1], mats[..., 1, 1])
-        elif convention == 'zyx':
-            alpha = np.arctan2(-mats[..., 0, 1], mats[..., 0, 0])
-            beta = np.arcsin(mats[..., 0, 2])
-            gamma = np.arctan2(-mats[..., 1, 2], mats[..., 2, 2])
-        elif convention == 'zxy':
-            alpha = np.arctan2(mats[..., 1, 0], mats[..., 1, 1])
-            beta = np.arcsin(-mats[..., 1, 2])
-            gamma = np.arctan2(mats[..., 0, 2], mats[..., 2, 2])
-        else:
-            raise ValueError("Unknown convention selected!")
-    else:
+    # For intrinsic angles, the matrix must be constructed in reverse order
+    # e.g. Z(\alpha)Y'(\beta)Z''(\gamma) (where primes denote the rotated
+    # frames) becomes the extrinsic rotation Z(\gamma)Y(\beta)Z(\alpha). Simply
+    # for easier readability of order, matrices are constructed for the
+    # intrinsic angle ordering and just reversed for extrinsic.
+    if axis_type == "extrinsic":
+        convention = convention[::-1]
+    elif not axis_type == "intrinsic":
         raise ValueError("The axis type must be either extrinsic or intrinsic")
 
+    # We have to hardcode the different convention possibilities since they all
+    # result in different matrices according to the rotation order. In all
+    # possible compositions, there are cases where, given some 0 elements in
+    # the matrix, the simplest combination of matrix elements will give the
+    # wrong solution.  In those cases, we have to use other parts of the
+    # matrix. In those cases, we have to be much more careful about signs,
+    # because there are multiple places where negatives can come into play. Due
+    # to gimbal lock, the alpha and gamma angles are no longer independent in
+    # that case. By convention, we set gamma to 0 and solve for alpha in those
+    # cases.
+
+    # Classical Euler angles
+    if convention == 'xzx':
+        beta = np.arccos(mats[..., 0, 0])
+        multiplier = mats[..., 0, 0] if axis_type == "extrinsic" else 1
+        where_zero = np.isclose(np.sin(beta), 0, atol=atol)
+
+        gamma = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 0, 2], -mats[..., 0, 1]))
+        alpha = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 2, 0], mats[..., 1, 0]))
+        zero_terms = np.arctan2(-multiplier*mats[..., 1, 2], mats[..., 2, 2])
+    elif convention == 'xyx':
+        beta = np.arccos(mats[..., 0, 0])
+        multiplier = mats[..., 0, 0] if axis_type == "extrinsic" else 1
+        where_zero = np.isclose(np.sin(beta), 0, atol=atol)
+
+        gamma = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 0, 1], mats[..., 0, 2]))
+        alpha = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 1, 0], -mats[..., 2, 0]))
+        zero_terms = np.arctan2(multiplier*mats[..., 2, 1], mats[..., 1, 1])
+    elif convention == 'yxy':
+        beta = np.arccos(mats[..., 1, 1])
+        multiplier = mats[..., 1, 1] if axis_type == "extrinsic" else 1
+        where_zero = np.isclose(np.sin(beta), 0, atol=atol)
+
+        gamma = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 1, 0], -mats[..., 1, 2]))
+        alpha = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 0, 1], mats[..., 2, 1]))
+        zero_terms = np.arctan2(-multiplier*mats[..., 2, 0], mats[..., 0, 0])
+    elif convention == 'yzy':
+        beta = np.arccos(mats[..., 1, 1])
+        multiplier = mats[..., 1, 1] if axis_type == "extrinsic" else 1
+        where_zero = np.isclose(np.sin(beta), 0, atol=atol)
+
+        gamma = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 1, 2], mats[..., 1, 0]))
+        alpha = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 2, 1], -mats[..., 0, 1]))
+        zero_terms = np.arctan2(multiplier*mats[..., 0, 2], mats[..., 2, 2])
+    elif convention == 'zyz':
+        beta = np.arccos(mats[..., 2, 2])
+        multiplier = mats[..., 2, 2] if axis_type == "extrinsic" else 1
+        where_zero = np.isclose(np.sin(beta), 0, atol=atol)
+
+        gamma = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 2, 1], -mats[..., 2, 0]))
+        alpha = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 1, 2], mats[..., 0, 2]))
+        zero_terms = np.arctan2(-multiplier*mats[..., 0, 1], mats[..., 1, 1])
+    elif convention == 'zxz':
+        beta = np.arccos(mats[..., 2, 2])
+        multiplier = mats[..., 2, 2] if axis_type == "extrinsic" else 1
+        where_zero = np.isclose(np.sin(beta), 0, atol=atol)
+
+        gamma = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 2, 0], mats[..., 2, 1]))
+        alpha = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 0, 2], -mats[..., 1, 2]))
+        zero_terms = np.arctan2(multiplier*mats[..., 1, 0], mats[..., 0, 0])
+    # Tait-Bryan angles
+    elif convention == 'xzy':
+        beta = np.arcsin(-mats[..., 0, 1])
+        where_zero = np.isclose(np.cos(beta), 0, atol=atol)
+
+        gamma = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 0, 2], mats[..., 0, 0]))
+        alpha = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 2, 1], mats[..., 1, 1]))
+        zero_terms = np.arctan2(-mats[..., 1, 2], mats[..., 2, 2])
+    elif convention == 'xyz':
+        beta = np.arcsin(mats[..., 0, 2])
+        multiplier = mats[..., 0, 2] if axis_type == "extrinsic" else 1
+        where_zero = np.isclose(np.cos(beta), 0, atol=atol)
+
+        gamma = np.where(where_zero, 0,
+                         np.arctan2(-mats[..., 0, 1], mats[..., 0, 0]))
+        alpha = np.where(where_zero, 0,
+                         np.arctan2(-mats[..., 1, 2], mats[..., 2, 2]))
+        zero_terms = np.arctan2(multiplier*mats[..., 2, 1], mats[..., 1, 1])
+    elif convention == 'yxz':
+        beta = np.arcsin(-mats[..., 1, 2])
+        multiplier = mats[..., 1, 2] if axis_type == "extrinsic" else 1
+        where_zero = np.isclose(np.cos(beta), 0, atol=atol)
+
+        gamma = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 1, 0], mats[..., 1, 1]))
+        alpha = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 0, 2], mats[..., 2, 2]))
+        zero_terms = np.arctan2(-multiplier*mats[..., 2, 0], mats[..., 0, 0])
+    elif convention == 'yzx':
+        beta = np.arcsin(mats[..., 1, 0])
+        multiplier = mats[..., 1, 0] if axis_type == "extrinsic" else 1
+        where_zero = np.isclose(np.cos(beta), 0, atol=atol)
+
+        gamma = np.where(where_zero, 0,
+                         np.arctan2(-mats[..., 1, 2], mats[..., 1, 1]))
+        alpha = np.where(where_zero, 0,
+                         np.arctan2(-mats[..., 2, 0], mats[..., 0, 0]))
+        zero_terms = np.arctan2(multiplier*mats[..., 0, 2], mats[..., 2, 2])
+    elif convention == 'zyx':
+        beta = np.arcsin(-mats[..., 2, 0])
+        where_zero = np.isclose(np.cos(beta), 0, atol=atol)
+
+        gamma = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 2, 1], mats[..., 2, 2]))
+        alpha = np.where(where_zero, 0,
+                         np.arctan2(mats[..., 1, 0], mats[..., 0, 0]))
+        zero_terms = np.arctan2(-mats[..., 0, 1], mats[..., 1, 1])
+    elif convention == 'zxy':
+        beta = np.arcsin(mats[..., 2, 1])
+        multiplier = mats[..., 2, 1] if axis_type == "extrinsic" else 1
+        where_zero = np.isclose(np.cos(beta), 0, atol=atol)
+
+        gamma = np.where(where_zero, 0,
+                         np.arctan2(-mats[..., 2, 0], mats[..., 2, 2]))
+        alpha = np.where(where_zero, 0,
+                         np.arctan2(-mats[..., 0, 1], mats[..., 1, 1]))
+        zero_terms = np.arctan2(multiplier*mats[..., 1, 0], mats[..., 0, 0])
+    else:
+        raise ValueError("Unknown convention selected!")
+
+    # For extrinsic, swap back alpha and gamma.
+    if axis_type == "extrinsic":
+        tmp = alpha
+        alpha = gamma
+        gamma = tmp
+
+    # By convention, the zero terms that we calculate are always based on
+    # setting gamma to zero and applying to alpha. We assign them after the
+    # fact to enable the memcopy-free swap of alpha and gamma for extrinsic
+    # angles. For Python 2 compatibility, we need to index appropriately.
+    try:
+        alpha[where_zero] = zero_terms[where_zero]
+    except IndexError:
+        # This is necessary for Python 2 compatibility and limitations with the
+        # indexing behavior. Since the only possible case is a single set of
+        # inputs, we can just skip any indexing and overwrite directly if
+        # needed.
+        if where_zero:
+            alpha = zero_terms
     return np.stack((alpha, beta, gamma), axis=-1)
 
 


Test patch:
diff --git a/tests/test_euler.py b/tests/test_euler.py
index f00c93f..bea72cd 100644
--- a/tests/test_euler.py
+++ b/tests/test_euler.py
@@ -181,3 +181,177 @@ class TestEuler(unittest.TestCase):
                     ),
                     msg="Failed for convention {}, axis type {}".format(
                         convention, axis_type))
+
+    def test_zero_beta(self):
+        """Check cases where beta is 0."""
+        # Since the Euler calculations are all done using matrices, it's easier
+        # to construct the test cases by directly using matrices as well. We
+        # assume gamma is 0 since, due to gimbal lock, only either alpha+gamma
+        # or alpha-gamma is a relevant parameter, and we just scan the other
+        # possible values. The actual function is defined such that gamma will
+        # always be zero in those cases. We define the matrices using lambda
+        # functions to support sweeping a range of values for alpha and beta,
+        # specifically to test cases where signs flip e.g. cos(0) vs cos(pi).
+        # These sign flips lead to changes in the rotation angles that must be
+        # tested.
+        mats_euler_intrinsic = [
+                ('xzx', 'intrinsic',
+                 lambda alpha, beta:
+                 [[np.cos(beta), 0, 0],
+                  [0, np.cos(beta)*np.cos(alpha), -np.sin(alpha)],
+                  [0, np.cos(beta)*np.sin(alpha), np.cos(alpha)]]),
+                ('xyx', 'intrinsic',
+                 lambda alpha, beta:
+                 [[np.cos(beta), 0, 0],
+                  [0, np.cos(alpha), -np.cos(beta)*np.sin(alpha)],
+                  [0, np.sin(alpha), np.cos(beta)*np.cos(alpha)]]),
+                ('yxy', 'intrinsic',
+                 lambda alpha, beta:
+                 [[np.cos(alpha), 0, np.cos(beta)*np.sin(alpha)],
+                  [0, np.cos(beta), 0],
+                  [-np.sin(alpha), 0, np.cos(beta)*np.cos(alpha)]]),
+                ('yzy', 'intrinsic',
+                 lambda alpha, beta:
+                 [[np.cos(beta)*np.cos(alpha), 0, np.sin(alpha)],
+                  [0, np.cos(beta), 0],
+                  [-np.cos(beta)*np.sin(alpha), 0, np.cos(alpha)]]),
+                ('zyz', 'intrinsic',
+                 lambda alpha, beta:
+                 [[np.cos(beta)*np.cos(alpha), -np.sin(alpha), 0],
+                  [np.cos(beta)*np.sin(alpha), np.cos(beta), 0],
+                  [0, 0, np.cos(beta)]]),
+                ('zxz', 'intrinsic',
+                 lambda alpha, beta:
+                 [[np.cos(alpha), -np.cos(beta)*np.sin(alpha), 0],
+                  [np.sin(alpha), np.cos(beta)*np.cos(beta), 0],
+                  [0, 0, np.cos(beta)]]),
+        ]
+
+        mats_tb_intrinsic = [
+                ('xzy', 'intrinsic',
+                 lambda alpha, beta:
+                 [[0, -np.sin(beta), 0],
+                  [np.sin(beta)*np.cos(alpha), 0, -np.sin(alpha)],
+                  [np.sin(beta)*np.sin(alpha), 0, np.cos(alpha)]]),
+                ('xyz', 'intrinsic',
+                 lambda alpha, beta:
+                 [[0, 0, np.sin(beta)],
+                  [np.sin(beta)*np.sin(alpha), np.cos(alpha), 0],
+                  [-np.sin(beta)*np.cos(alpha), np.sin(alpha), 0]]),
+                ('yxz', 'intrinsic',
+                 lambda alpha, beta:
+                 [[np.cos(alpha), np.sin(beta)*np.sin(alpha), 0],
+                  [0, 0, -np.sin(beta)],
+                  [-np.sin(alpha), np.sin(beta)*np.cos(alpha), 0]]),
+                ('yzx', 'intrinsic',
+                 lambda alpha, beta:
+                 [[0, -np.sin(beta)*np.cos(alpha), np.sin(alpha)],
+                  [np.sin(beta), 0, 0],
+                  [0, np.sin(beta)*np.sin(alpha), np.cos(alpha)]]),
+                ('zyx', 'intrinsic',
+                 lambda alpha, beta:
+                 [[0, -np.sin(alpha), np.sin(beta)*np.cos(alpha)],
+                  [0, np.cos(alpha), np.sin(beta)*np.sin(alpha)],
+                  [-np.sin(beta), 0, 0]]),
+                ('zxy', 'intrinsic',
+                 lambda alpha, beta:
+                 [[np.cos(alpha), 0, np.sin(beta)*np.sin(alpha)],
+                  [np.sin(alpha), 0, -np.sin(beta)*np.cos(alpha)],
+                  [0, -1, 0]]),
+        ]
+
+        # Extrinsic rotations can be tested identically to intrinsic rotations
+        # in the case of proper Euler angles.
+        mats_euler_extrinsic = [
+            (m[0], 'extrinsic', m[2]) for m in mats_euler_intrinsic
+        ]
+
+        # For Tait-Bryan angles, extrinsic rotations axis order must be
+        # reversed (since axes 1 and 3 are not identical), but more
+        # importantly, due to the sum/difference of alpha and gamma that
+        # arises, we need to test the negative of alpha to catch the dangerous
+        # cases. In practice we get the same results since we're sweeping alpha
+        # values in the tests below, but it's useful to set this up precisely.
+        mats_tb_extrinsic = [
+            (m[0][::-1], 'extrinsic',
+             lambda alpha, beta: m[2](-alpha, beta)
+             ) for m in mats_tb_intrinsic
+        ]
+
+        # Since angle representations may not be unique, checking that
+        # quaternions are equal may not work. Instead we perform rotations and
+        # check that they are identical.  For simplicity, we rotate the
+        # simplest vector with all 3 components (otherwise tests won't catch
+        # the problem because there's no component to rotate).
+        test_vector = [1, 1, 1]
+
+        mats_intrinsic = (mats_euler_intrinsic, mats_tb_intrinsic)
+        mats_extrinsic = (mats_euler_extrinsic, mats_tb_extrinsic)
+
+        # The beta angles are different for proper Euler angles and Tait-Bryan
+        # angles because the relevant beta terms will be sines and cosines,
+        # respectively.
+        all_betas = ((0, np.pi), (np.pi/2, -np.pi/2))
+        alphas = (0, np.pi/2, np.pi, 3*np.pi/2)
+
+        for mats in (mats_intrinsic, mats_extrinsic):
+            for betas, mat_set in zip(all_betas, mats):
+                for convention, axis_type, mat_func in mat_set:
+                    quaternions = []
+                    for beta in betas:
+                        for alpha in alphas:
+                            mat = mat_func(alpha, beta)
+                            if np.linalg.det(mat) == -1:
+                                # Some of these will be improper rotations.
+                                continue
+                            quat = rowan.from_matrix(mat)
+                            quaternions.append(quat)
+                            euler = rowan.to_euler(
+                                    quat,
+                                    convention, axis_type
+                                    )
+                            converted = rowan.from_euler(
+                                *euler,
+                                convention=convention,
+                                axis_type=axis_type
+                            )
+                            correct_rotation = rowan.rotate(quat,
+                                                            test_vector)
+                            test_rotation = rowan.rotate(converted,
+                                                         test_vector)
+                            self.assertTrue(
+                                np.allclose(
+                                    correct_rotation,
+                                    test_rotation,
+                                    atol=1e-6
+                                ),
+                                msg="""
+                                       Failed for convention {},
+                                       axis type {},
+                                       alpha = {},
+                                       beta = {}.
+                                       Expected quaternion: {}.
+                                       Calculated: {}.
+                                       Expected vector: {}.
+                                       Calculated vector: {}.""".format(
+                                            convention, axis_type,
+                                            alpha, beta, quat,
+                                            converted, correct_rotation,
+                                            test_rotation))
+
+                    # For completeness, also test with broadcasting.
+                    quaternions = np.asarray(quaternions).reshape(-1,  4)
+                    all_euler = rowan.to_euler(
+                                    quaternions,
+                                    convention, axis_type
+                                    )
+                    converted = rowan.from_euler(
+                        all_euler[..., 0], all_euler[..., 1], all_euler[..., 2],
+                        convention, axis_type
+                    )
+                    self.assertTrue(
+                        np.allclose(
+                            rowan.rotate(quaternions, test_vector),
+                            rowan.rotate(converted, test_vector),
+                            atol=1e-6
+                        ))