Repository: scipy/scipy
Pull request: ENH: Rotation.concatenate
<!-- 
Thanks for contributing a pull request! Please ensure that
your PR satisfies the checklist before submitting:
http://scipy.github.io/devdocs/dev/contributor/development_workflow.html#checklist-before-submitting-a-pr

Also, please name and describe your PR as you would write a
commit message:
https://docs.scipy.org/doc/numpy/dev/development_workflow.html#writing-the-commit-message

Note that we are a team of volunteers; we appreciate your
patience during the review process.

Again, thanks for contributing!
-->

#### Reference issue
<!--Example: Closes gh-WXYZ.-->
Closes #14340

#### What does this implement/fix?
<!--Please explain your changes.-->
This adds a `concatenate` method similar to `np.concatenate` but for `Rotation` objects.

@nmayorov what do you think?

Problem statement:
Initialize Rotation using list or array of Rotations
Currently you can convert a Rotation object of multiple rotations into an array with `np.asarray`.
You can't however convert a list of N (single) Rotation objects into a Rotation object with N rotations (as far as I can tell).
This is useful when you have a list of Rotations and want to use e.g. Slerp, which takes in a single Rotation object with multiple rotations.

Perhaps something like
```
# Imagine this comes from somewhere else.
arr = [ R.from_euler('ZYX', [0.1, 0, 0]), R.from_euler('ZYX', [0.2, 0.1, 0])]
rots = R(arr) # or
rots = R.from_array(arr)
# Use rots with Slerp here e.g.
```

Currently, I'm using `R.from_matrix([rot.as_matrix() for rot in arr])`, which doesn't seem ideal.

Hints from issue discussion:

Commit message: simplify concatenate logic

Patch to review:
diff --git a/scipy/spatial/transform/rotation.pyx b/scipy/spatial/transform/rotation.pyx
index 2e5a90d1c775..711b0fd3e70a 100644
--- a/scipy/spatial/transform/rotation.pyx
+++ b/scipy/spatial/transform/rotation.pyx
@@ -333,6 +333,7 @@ cdef class Rotation:
     as_rotvec
     as_mrp
     as_euler
+    concatenate
     apply
     __mul__
     inv
@@ -1572,6 +1573,30 @@ cdef class Rotation:
         else:
             return np.asarray(mrps)
 
+    @classmethod
+    def concatenate(cls, rotations):
+        """Concatenate a sequence of `Rotation` objects.
+
+        Parameters
+        ----------
+        rotations : sequence of `Rotation` objects
+            The rotations to concatenate.
+
+        Returns
+        -------
+        concatenated : `Rotation` instance
+            The concatenated rotations.
+
+        Notes
+        -----
+        .. versionadded:: 1.8.0
+        """
+        if not all([isinstance(x, Rotation) for x in rotations]):
+            raise TypeError("input must contain Rotation objects only")
+
+        quats = np.concatenate([np.atleast_2d(x.as_quat()) for x in rotations])
+        return cls(quats, normalize=False)
+
     def apply(self, vectors, inverse=False):
         """Apply this rotation to a set of vectors.
 
diff --git a/scipy/spatial/transform/tests/test_rotation.py b/scipy/spatial/transform/tests/test_rotation.py
index 5cfac43d285f..acace5db8a6f 100644
--- a/scipy/spatial/transform/tests/test_rotation.py
+++ b/scipy/spatial/transform/tests/test_rotation.py
@@ -1298,3 +1298,16 @@ def test_as_euler_contiguous():
     assert all(i >= 0 for i in e1.strides)
     assert all(i >= 0 for i in e2.strides)
 
+
+def test_concatenate():
+    rotation = Rotation.random(10, random_state=0)
+    sizes = [1, 2, 3, 1, 3]
+    starts = [0] + list(np.cumsum(sizes))
+    split = [rotation[i:i + n] for i, n in zip(starts, sizes)]
+    result = Rotation.concatenate(split)
+    assert_equal(rotation.as_quat(), result.as_quat())
+
+
+def test_concatenate_wrong_type():
+    with pytest.raises(TypeError, match='Rotation objects only'):
+        Rotation.concatenate([Rotation.identity(), 1, None])


Code review comments:
diff_hunk: @@ -1572,6 +1573,30 @@ cdef class Rotation:
         else:
             return np.asarray(mrps)
 
+    @classmethod
+    def concatenate(cls, rotations):
+        """Concatenate a sequence of `Rotation` objects.
+
+        Parameters
+        ----------
+        rotations : sequence of `Rotation` objects
+            The rotations to concatenate.
+
+        Returns
+        -------
+        concatenated : `Rotation` instance
+            The concatenated rotations.
+
+        Notes
+        -----
+        .. versionadded:: 1.8.0
+        """
+        if not all([isinstance(x, Rotation) for x in rotations]):
path: scipy/spatial/transform/rotation.pyx
text: @user1:
I assume that `[]` won't be necessary and it is even more idiomatic to omit it here.

@author:
Thanks Nikolay. Done.