Repository: obspy/obspy

Issue:
Stream.rotate() with multiple-station Streams not implemented?
I ran into an issue today trying to rotate a Stream containing multiple stations from `'NE->RT'`. Each Trace has `stats.back_azimuth` defined; of course for different stations the values are different. I kept on getting weird results until I realized that the code for `Stream.rotate()` uses the `stats.back_azimuth` of the first Trace in the Stream. This clearly does not work for mixed-station Streams.

I guess this issue is either a feature request (for mixed-station Stream rotation) or a documentation suggestion (for more clarity that `st[0].stats.back_azimuth` is used **and overwrites the value for all other Traces**), since I had to dig a bit to realize that `Stream.rotate()` only works on single-station Streams.

My workaround for this is
```python
import numpy as np

for station in np.unique([tr.stats.station for tr in st]):
    st.select(station=station).rotate('NE->RT')
``` 
which is not much of a pain. Thanks all, I hope this isn't a dupe (didn't see it in my search).

**Setup details:**

* Python version: 3.8.3
* ObsPy version: 1.2.1 installed via conda
* macOS Mojave


Discussion:
Hi Liam, thanks for reporting this issue. This behaviour could definitely be improved. It's not only a problem for data of different stations and one event, but also for a single station and multiple events. Also, rotate will have problems if - for some reason - the traces in the stream are not "correctly" ordered. Related to #1918.
Thanks for your response, Tom.

> Also, rotate will have problems if - for some reason - the traces in the stream are not "correctly" ordered.

What do you mean by not correctly ordered?
> What do you mean by not correctly ordered?

Considering a stream of traces `ST1..Z, ST2..N, ST1..E, ST2..Z, ST1..N, ST2..E` rotate will mix data of different stations.
Yikes! Though I guess that is an unlikely scenario.
This is indeed really not ideal.. without looking at all the detials, I think we should change it to something like this:

```python
class Stream():
    def rotate(self, *args, **kwargs):
        st_new = Stream()
        seed_ids = set([tr.id[:-1] + '?' for tr in st])
        for seed_id in seed_ids:
            net, sta, loc, cha = seed_id.split('.')
            st_new += self.select(net, sta, loc, cha)._rotate(*args, **kwargs)
        self.traces = st_new.traces
        return self
    def _rotate(self, ...):
        # current rotate() code
```
I also ran into the same issue today and it takes time to find out why a simple rotation doesn't work as expected.

It's very common that a Stream is for multiple stations and multiple events. It's intuitive to expect that the rotate method will use the trace's own `tr.stats.back_azimuth` attribute when doing the rotation.

IMHO, this is a severe bug and should be fixed ASAP. 

I'd like to help if you agree that this should be fixed.

I feel that there is still a problem when one stream include multiple stations and one event.
@seisman sure thing, please feel free to work on a PR! The above code snippet should go a long ways anyways and it's quite straight forward.. just never enough time in one day
> It's intuitive to expect that the rotate method will use the trace's own `tr.stats.back_azimuth` attribute when doing the rotation.

without having looked at this in a while, our main focus and the main use case advertized should definitely be to have the rotation rely on proper metadata as an Inventory object. Those old legacy ways of crutches attached to `.stats` are all very patchy and should in general be discouraged by documentation =)

Resolving patch:
diff --git a/obspy/core/stream.py b/obspy/core/stream.py
--- a/obspy/core/stream.py
+++ b/obspy/core/stream.py
@@ -2749,6 +2749,35 @@ def rotate(self, method, back_azimuth=None, inclination=None,
         >>> st.rotate(method="->ZNE", inventory=inv)  # doctest: +ELLIPSIS
         <obspy.core.stream.Stream object at 0x...>
         """
+        # check if we have a mix of multiple SEED ID groups that need to be
+        # handled separately
+        seed_id_groups = {}
+        for tr in self:
+            net, sta, loc, cha = tr.id.split(".")
+            if not len(cha):
+                msg = "Channel code must be at least one character long."
+                raise ValueError(msg)
+            comp = cha[-1]
+            cha_prefix = cha[:-1]
+            seed_id_groups[(net, sta, loc, cha_prefix)] = None
+        # recursively rotate each set of matching SEED IDs if needed
+        if len(seed_id_groups) > 1:
+            new_traces = []
+            for net, sta, loc, cha_prefix in seed_id_groups:
+                st = Stream()
+                for tr in self:
+                    if (tr.stats.network == net and tr.stats.station == sta and
+                            tr.stats.location == loc and
+                            tr.stats.channel[:-1] == cha_prefix):
+                        st += tr
+                st.rotate(method, back_azimuth=back_azimuth,
+                          inclination=inclination, inventory=inventory,
+                          **kwargs)
+                new_traces.extend(st.traces)
+            self.traces = new_traces
+            return self
+
+        # if working on a single SEED ID group just do the requested rotation
         if method == "->ZNE":
             if inventory is None:
                 msg = ("With method '->ZNE' station metadata has to be "
@@ -2772,19 +2801,6 @@ def rotate(self, method, back_azimuth=None, inclination=None,
         # Split to get the components. No need for further checks for the
         # method as invalid methods will be caught by previous conditional.
         input_components, output_components = method.split("->")
-        # Figure out inclination and back-azimuth.
-        if back_azimuth is None:
-            try:
-                back_azimuth = self[0].stats.back_azimuth
-            except Exception:
-                msg = "No back-azimuth specified."
-                raise TypeError(msg)
-        if len(input_components) == 3 and inclination is None:
-            try:
-                inclination = self[0].stats.inclination
-            except Exception:
-                msg = "No inclination specified."
-                raise TypeError(msg)
         # Do one of the two-component rotations.
         if len(input_components) == 2:
             input_1 = self.select(component=input_components[0])
@@ -2798,7 +2814,15 @@ def rotate(self, method, back_azimuth=None, inclination=None,
                     msg = "All components need to have the same time span."
                     raise ValueError(msg)
             for i_1, i_2 in zip(input_1, input_2):
-                output_1, output_2 = func(i_1.data, i_2.data, back_azimuth)
+                # Figure out back-azimuth.
+                baz = back_azimuth
+                if baz is None:
+                    try:
+                        baz = i_1.stats.back_azimuth
+                    except Exception:
+                        msg = "No back-azimuth specified."
+                        raise TypeError(msg)
+                output_1, output_2 = func(i_1.data, i_2.data, baz)
                 i_1.data = output_1
                 i_2.data = output_2
                 # Rename the components.
@@ -2808,7 +2832,7 @@ def rotate(self, method, back_azimuth=None, inclination=None,
                     output_components[1]
                 # Add the azimuth and inclination to the stats object.
                 for comp in (i_1, i_2):
-                    comp.stats.back_azimuth = back_azimuth
+                    comp.stats.back_azimuth = baz
         # Do one of the three-component rotations.
         else:
             input_1 = self.select(component=input_components[0])
@@ -2827,8 +2851,23 @@ def rotate(self, method, back_azimuth=None, inclination=None,
                     msg = "All components need to have the same time span."
                     raise ValueError(msg)
             for i_1, i_2, i_3 in zip(input_1, input_2, input_3):
+                # Figure out inclination and back-azimuth.
+                baz = back_azimuth
+                inc = inclination
+                if baz is None:
+                    try:
+                        baz = i_1.stats.back_azimuth
+                    except Exception:
+                        msg = "No back-azimuth specified."
+                        raise TypeError(msg)
+                if inc is None:
+                    try:
+                        inc = i_1.stats.inclination
+                    except Exception:
+                        msg = "No inclination specified."
+                        raise TypeError(msg)
                 output_1, output_2, output_3 = func(
-                    i_1.data, i_2.data, i_3.data, back_azimuth, inclination)
+                    i_1.data, i_2.data, i_3.data, baz, inc)
                 i_1.data = output_1
                 i_2.data = output_2
                 i_3.data = output_3
@@ -2841,8 +2880,8 @@ def rotate(self, method, back_azimuth=None, inclination=None,
                     output_components[2]
                 # Add the azimuth and inclination to the stats object.
                 for comp in (i_1, i_2, i_3):
-                    comp.stats.back_azimuth = back_azimuth
-                    comp.stats.inclination = inclination
+                    comp.stats.back_azimuth = baz
+                    comp.stats.inclination = inc
         return self
 
     def copy(self):


Test patch:
diff --git a/obspy/core/tests/test_stream.py b/obspy/core/tests/test_stream.py
--- a/obspy/core/tests/test_stream.py
+++ b/obspy/core/tests/test_stream.py
@@ -2032,12 +2032,16 @@ def test_rotate(self):
         # again, with angles given in stats and just 2 components
         st = st2.copy()
         st = st[1:3] + st[4:]
-        st[0].stats.back_azimuth = 190
-        st[2].stats.back_azimuth = 200
+        for tr in st[:2]:
+            tr.stats.back_azimuth = 190
+        for tr in st[2:]:
+            tr.stats.back_azimuth = 200
+            tr.stats.starttime = tr.stats.starttime + 1000
         st.rotate(method='NE->RT')
         st.rotate(method='RT->NE')
         assert np.allclose(st[0].data, st2[1].data)
         assert np.allclose(st[1].data, st2[2].data)
+        assert st[2].stats.back_azimuth == 200
         # rotate to LQT and back with 6 traces
         st = st2.copy()
         st.rotate(method='ZNE->LQT', back_azimuth=100, inclination=30)
@@ -2057,12 +2061,15 @@ def test_rotate(self):
         with pytest.raises(ValueError):
             st.rotate(method='UNKNOWN')
         # rotating without back_azimuth raises TypeError
-        st = Stream()
+        st = read()
+        for tr in st:
+            del tr.stats.back_azimuth
+            del tr.stats.inclination
         with pytest.raises(TypeError):
-            st.rotate(method='RT->NE')
+            st.rotate(method='NE->RT')
         # rotating without inclination raises TypeError for LQT-> or ZNE->
         with pytest.raises(TypeError):
-            st.rotate(method='LQT->ZNE', back_azimuth=30)
+            st.rotate(method='ZNE->LQT', back_azimuth=30)
         # having traces with different timespans or sampling rates will fail
         st = read()
         st[1].stats.sampling_rate = 2.0
@@ -2688,6 +2695,8 @@ def test_rotate_to_zne(self):
             # do some checks on results
             assert len(st) == 30
             # compare data
+            st.sort()
+            st_expected.sort()
             for tr_got, tr_expected in zip(st, st_expected):
                 np.testing.assert_allclose(tr_got.data, tr_expected.data,
                                            rtol=1e-7)