Repository: obspy/obspy

Issue:
Bug in rotate2zne with non-orthogonal components
obspy version: 1.0.3
installed with anaconda
According to the documentation, rotate2zne works explicitly also for non orthogonal systems. Looking into the source code, I realized, that for the basis transformation from an arbitrary system (u v w) to (z n e), the **transposed** basis vector matrix is taken. In my opinion, the **inverse** matrix must be taken.
For the transformation from one orthogonal system to another orthogonal system, the transposed matrix is equal to the inverse matrix. But in general, for non orthogonal systems, this is not the case. 
I will run some test on this issue and let you know about the results, soon.





Discussion:
I think some of this was discussed in #1061.
I think there just was some confusion about the *transpose* being used.. but that's probably just owed to how the base vectors are set up (ie. as rows as opposed to columns), and hasn't anything to do with moving the matrix to the other side of the equation.
The *matrix-inverse* is in the code alright, when using `inverse=True`. So, I think there actually is no bug and this can be closed (although we currently only have a round-trip test for non-orthogonal input and the intermediate result should be tested as well).
Ok, so we *do* have a bug in reconstruction of ZNE components from a set of three non-orthogonal components. We have a sane real data test from two seismometers on a step table now, and we also have a working fix that has our old tests passing. I'll prepare a PR with the fix now.

Thanks to @jwassermann and @fbernauer for the code fix and many thanks for recording the test data to @fbernauer!
So, here's what we currently have for asserting correctness of reconstructing orthogonal components from three arbitrary ones. This is using the patch from @jwassermann that I'll push in a bit.. we're fairly confident now, that this works correctly now. The only shortcoming of below test is probably that we reconstruct UVW and those three components basically have the same signal (due to the step table operating vertically). Maybe we should pimp the testing by comparing..
 - simple rotation from UVW to a slightly rotated orthogonal system
 - reconstruction into that same system from a set of three somponents taken from U/V/W/X/Y/Z

```python
import matplotlib.pyplot as plt
from obspy import read, UTCDateTime
from obspy.signal.rotate import rotate2zne


st = read('http://examples.obspy.org/step_table_galperin_and_xyz.mseed')
# remove step table signal trace, it has 'HXZ' channel code and gets in the way
# when selecting the second seismometer's Z trace
st = st.select(station='TRC*')
print(st)

t1 = UTCDateTime('2017-10-11T12:33:15')
t2 = UTCDateTime('2017-10-11T12:33:40')

st.filter('bandpass', freqmin=0.05, freqmax=10)
st.detrend('constant')
st.trim(t1, t2)

t = st[0].times()

az_u = 90.0
dip_u = -(90.0 - 54.7)
az_v = 330.0
dip_v = -(90.0 - 54.7)
az_w = 210.0
dip_w = -(90.0 - 54.7)
az_x = 65.0
dip_x = 0.0
az_y = 335.0
dip_y = 0.0
az_z = 0.0
dip_z = -90.0

azimuth = {
    'U': az_u,
    'V': az_v,
    'W': az_w,
    'X': az_x,
    'Y': az_y,
    'Z': az_z,
    }

dip = {
    'U': dip_u,
    'V': dip_v,
    'W': dip_w,
    'X': dip_x,
    'Y': dip_y,
    'Z': dip_z,
    }


def zne_from(components='UVW'):
    traces = [st.select(component=c)[0] for c in components]
    dips = [dip[c] for c in components]
    az = [azimuth[c] for c in components]
    z, n, e = rotate2zne(traces[0], az[0], dips[0],
                         traces[1], az[1], dips[1],
                         traces[2], az[2], dips[2])
    return z, n, e


def uvw_from(z, n, e):
    traces = [z, n, e]
    dips = [dip_u, dip_v, dip_w]
    az = [az_u, az_v, az_w]
    u, v, w = rotate2zne(traces[0], az[0], dips[0],
                         traces[1], az[1], dips[1],
                         traces[2], az[2], dips[2],
                         inverse=True)
    return u, v, w


fig, axes = plt.subplots(nrows=3)
fig2, axes2 = plt.subplots(nrows=3)

# plot directly recorded UVW in second figure
for ax, comp in zip(axes2, 'UVW'):
    data = st.select(component=comp)[0]
    ax.plot(t, data, alpha=0.6, label='original UVW')

# reconstruct UVW from various component combinations
for components in ['UVW', 'XYZ', 'UYZ', 'XWZ']:
    # first reconstruct ZNE
    z, n, e = zne_from(components)
    for data, ax, label in zip([z, n, e], axes, 'ZNE'):
        ax.plot(t, data, alpha=0.6, label='from ' + components)
        ax.set_ylabel(label)
    # then reconstruct UVW from ZNE with inverse option in rotate2zne
    u, v, w = uvw_from(z, n, e)
    for data, ax2, label in zip([u, v, w], axes2, 'UVW'):
        # for the second figure we already plotted the original UVW recorded
        # data
        if components == 'UVW':
            continue
        ax2.plot(t, data, alpha=0.6, label='from ' + components)
        ax2.set_ylabel(label)

for ax in (axes[-1], axes2[-1]):
    ax.set_xlabel("time")
    ax.legend(loc='lower center')
for fig_ in (fig, fig2):
    fig_.tight_layout()
plt.show()
```

![figure_1](https://user-images.githubusercontent.com/1842780/31501394-6eb55de4-af6a-11e7-8f38-821b14b2d383.png)
![figure_2](https://user-images.githubusercontent.com/1842780/31501398-72a98f9c-af6a-11e7-93e1-0dfa74446184.png)


Resolving patch:
diff --git a/obspy/signal/rotate.py b/obspy/signal/rotate.py
--- a/obspy/signal/rotate.py
+++ b/obspy/signal/rotate.py
@@ -131,36 +131,19 @@ def rotate_lqt_zne(l, q, t, ba, inc):
     return z, n, e
 
 
-def _dip_azimuth2zse_base_vector(dip, azimuth):
+def _dip_azimuth2zne_base_vector(dip, azimuth):
     """
     Helper function converting a vector described with azimuth and dip of unit
-    length to a vector in the ZSE (Vertical, South, East) base.
+    length to a vector in the ZNE (Vertical, North, East) base.
 
     The definition of azimuth and dip is according to the SEED reference
-    manual, as are the following examples (they use rounding for small
-    numerical inaccuracies - also positive and negative zero are treated as
-    equal):
-
-    >>> r = lambda x: np.array([_i if _i != -0.0 else 0.0\
-        for _i in np.round(x, 10)])
-    >>> r(_dip_azimuth2zse_base_vector(-90, 0)) #doctest: +NORMALIZE_WHITESPACE
-    array([ 1., 0., 0.])
-    >>> r(_dip_azimuth2zse_base_vector(90, 0)) #doctest: +NORMALIZE_WHITESPACE
-    array([-1., 0., 0.])
-    >>> r(_dip_azimuth2zse_base_vector(0, 0)) #doctest: +NORMALIZE_WHITESPACE
-    array([ 0., -1., 0.])
-    >>> r(_dip_azimuth2zse_base_vector(0, 180)) #doctest: +NORMALIZE_WHITESPACE
-    array([ 0., 1., 0.])
-    >>> r(_dip_azimuth2zse_base_vector(0, 90)) #doctest: +NORMALIZE_WHITESPACE
-    array([ 0., 0., 1.])
-    >>> r(_dip_azimuth2zse_base_vector(0, 270)) #doctest: +NORMALIZE_WHITESPACE
-    array([ 0., 0., -1.])
+    manual.
     """
     dip = np.deg2rad(dip)
     azimuth = np.deg2rad(azimuth)
 
     return np.array([-np.sin(dip),
-                     -np.cos(azimuth) * np.cos(dip),
+                     np.cos(azimuth) * np.cos(dip),
                      np.sin(azimuth) * np.cos(dip)])
 
 
@@ -220,33 +203,39 @@ def rotate2zne(data_1, azimuth_1, dip_1, data_2, azimuth_2, dip_2, data_3,
         msg = "All three data arrays must be of same length."
         raise ValueError(msg)
 
-    # Internally works in Vertical, South, and East components; a right handed
-    # coordinate system.
-
     # Define the base vectors of the old base in terms of the new base vectors.
-    base_vector_1 = _dip_azimuth2zse_base_vector(dip_1, azimuth_1)
-    base_vector_2 = _dip_azimuth2zse_base_vector(dip_2, azimuth_2)
-    base_vector_3 = _dip_azimuth2zse_base_vector(dip_3, azimuth_3)
-
-    # Build transformation matrix.
-    _t = np.matrix([base_vector_1, base_vector_2, base_vector_3]).transpose()
+    base_vector_1 = _dip_azimuth2zne_base_vector(dip_1, azimuth_1)
+    base_vector_2 = _dip_azimuth2zne_base_vector(dip_2, azimuth_2)
+    base_vector_3 = _dip_azimuth2zne_base_vector(dip_3, azimuth_3)
+
+    # Base change matrix.
+    m = np.array([base_vector_1,
+                  base_vector_2,
+                  base_vector_3])
+
+    # Determinant gives the volume change of a unit cube going from one
+    # basis to the next. It should neither be too small nor to large. These
+    # here are arbitrary limits.
+    if not (1E-6 < abs(np.linalg.det(m)) < 1E6):
+        raise ValueError("The given directions are not linearly independent, "
+                         "at least within numerical precision. Determinant "
+                         "of the base change matrix: %g" % np.linalg.det(m))
 
     if not inverse:
-        x, y, z = np.dot(_t, [data_1, data_2, data_3])
-        y *= -1
-    else:
-        x, y, z = np.dot(np.linalg.inv(_t), [data_1, -data_2, data_3])
+        m = np.linalg.inv(m)
+
+    z, n, e = np.dot(m, [data_1, data_2, data_3])
 
     # Replace all negative zeros. These might confuse some further
     # processing programs.
-    x = np.array(x).ravel()
-    x[x == -0.0] = 0
-    y = np.array(y).ravel()
-    y[y == -0.0] = 0
     z = np.array(z).ravel()
     z[z == -0.0] = 0
+    n = np.array(n).ravel()
+    n[n == -0.0] = 0
+    e = np.array(e).ravel()
+    e[e == -0.0] = 0
 
-    return x, y, z
+    return z, n, e
 
 
 if __name__ == '__main__':


Test patch:
diff --git a/obspy/io/xseed/tests/test_parser.py b/obspy/io/xseed/tests/test_parser.py
--- a/obspy/io/xseed/tests/test_parser.py
+++ b/obspy/io/xseed/tests/test_parser.py
@@ -796,7 +796,7 @@ def test_rotation_to_zne(self):
         self.assertTrue(np.allclose(energy_before, energy_after))
 
         # The vertical channel should not have changed at all.
-        np.testing.assert_array_equal(tr_z.data, tr_r_z.data)
+        np.testing.assert_allclose(tr_z.data, tr_r_z.data, rtol=1e-10)
         # The other two are only rotated by 2 degree so should also not have
         # changed much but at least a little bit. And the components should be
         # renamed.
diff --git a/obspy/signal/tests/test_rotate.py b/obspy/signal/tests/test_rotate.py
--- a/obspy/signal/tests/test_rotate.py
+++ b/obspy/signal/tests/test_rotate.py
@@ -9,13 +9,14 @@
 from future.utils import PY2
 
 import gzip
+import itertools
 import os
 import unittest
 
 import numpy as np
 
 from obspy.signal.rotate import (rotate_lqt_zne, rotate_ne_rt, rotate_rt_ne,
-                                 rotate_zne_lqt, _dip_azimuth2zse_base_vector,
+                                 rotate_zne_lqt, _dip_azimuth2zne_base_vector,
                                  rotate2zne)
 
 
@@ -126,24 +127,29 @@ def test_rotate2zne_round_trip(self):
         The rotate2zne() function has an inverse argument. Thus round
         tripping should work.
         """
-        z = np.ones(10, dtype=np.float64)
-        n = 2.0 * np.ones(10, dtype=np.float64)
-        e = 3.0 * np.ones(10, dtype=np.float64)
+        np.random.seed(45645623)
+        z = np.random.random(10)
+        n = np.random.random(10)
+        e = np.random.random(10)
 
-        # Random values.
-        dip_1, dip_2, dip_3 = 0.0, 30.0, 60.0
-        azi_1, azi_2, azi_3 = 0.0, 170.0, 35.0
+        for _ in range(100):
+            # The risk of producing linear dependent directions is very
+            # small (the seed value should also prevent it across machines).
+            dip_1, dip_2, dip_3 = np.random.random(3) * 180.0 - 90.0
+            azi_1, azi_2, azi_3 = np.random.random(3) * 360.0
 
-        a, b, c = rotate2zne(z, azi_1, dip_1, n, azi_2, dip_2, e, azi_3, dip_3)
+            a, b, c = rotate2zne(z, azi_1, dip_1,
+                                 n, azi_2, dip_2,
+                                 e, azi_3, dip_3)
 
-        z_new, n_new, e_new = rotate2zne(a, azi_1, dip_1,
-                                         b, azi_2, dip_2,
-                                         c, azi_3, dip_3,
-                                         inverse=True)
+            z_new, n_new, e_new = rotate2zne(a, azi_1, dip_1,
+                                             b, azi_2, dip_2,
+                                             c, azi_3, dip_3,
+                                             inverse=True)
 
-        self.assertTrue(np.allclose(z, z_new, rtol=1E-7, atol=1e-7))
-        self.assertTrue(np.allclose(n, n_new, rtol=1E-7, atol=1e-7))
-        self.assertTrue(np.allclose(e, e_new, rtol=1E-7, atol=1e-7))
+            np.testing.assert_allclose(z, z_new, rtol=1E-7, atol=1e-7)
+            np.testing.assert_allclose(n, n_new, rtol=1E-7, atol=1e-7)
+            np.testing.assert_allclose(e, e_new, rtol=1E-7, atol=1e-7)
 
     def test_rotate2zne_raise(self):
         """
@@ -165,20 +171,75 @@ def test_rotate2zne_raise(self):
             ValueError, 'All three data arrays must be of same length.',
             rotate2zne, z, azi_1, dip_1, n, azi_2, dip_2, e, azi_3, dip_3)
 
+    def test_base_vector_calculation_simple_cases(self):
+        """
+        Tests the _dip_azimuth2zne_base_vector() with some simple cases.
+        """
+        # Up and down.
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(-90, 0),
+                                   [1.0, 0.0, 0.0], atol=1E-10)
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(90, 0),
+                                   [-1.0, 0.0, 0.0], atol=1E-10)
+        # North and South.
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(0, 0),
+                                   [0.0, 1.0, 0.0], atol=1E-10)
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(0, 180),
+                                   [0.0, -1.0, 0.0], atol=1E-10)
+        # East and West.
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(0, 90),
+                                   [0.0, 0.0, 1.0], atol=1E-10)
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(0, 270),
+                                   [0.0, 0.0, -1.0], atol=1E-10)
+
+        # Normalizing helper.
+        def _n(v):
+            return np.array(v) / np.linalg.norm(np.array(v))
+
+        # 4 corners in the plain.
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(0, 45),
+                                   _n([0.0, 1.0, 1.0]), atol=1E-10)
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(0, 135),
+                                   _n([0.0, -1.0, 1.0]), atol=1E-10)
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(0, 225),
+                                   _n([0.0, -1.0, -1.0]), atol=1E-10)
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(0, 315),
+                                   _n([0.0, 1.0, -1.0]), atol=1E-10)
+
+        # 4 corners in the top.
+        dip = np.rad2deg(np.arctan2(1, np.sqrt(2)))
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(-dip, 45),
+                                   _n([1.0, 1.0, 1.0]), atol=1E-10)
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(-dip, 135),
+                                   _n([1.0, -1.0, 1.0]), atol=1E-10)
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(-dip, 225),
+                                   _n([1.0, -1.0, -1.0]), atol=1E-10)
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(-dip, 315),
+                                   _n([1.0, 1.0, -1.0]), atol=1E-10)
+
+        # And in the bottom
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(dip, 45),
+                                   _n([-1.0, 1.0, 1.0]), atol=1E-10)
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(dip, 135),
+                                   _n([-1.0, -1.0, 1.0]), atol=1E-10)
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(dip, 225),
+                                   _n([-1.0, -1.0, -1.0]), atol=1E-10)
+        np.testing.assert_allclose(_dip_azimuth2zne_base_vector(dip, 315),
+                                   _n([-1.0, 1.0, -1.0]), atol=1E-10)
+
     def test_base_vector_from_azimuth_and_dip_calculation(self):
         """
-        Tests the _dip_azimuth2zse_base_vector() method against a solution
+        Tests the _dip_azimuth2zne_base_vector() method against a solution
         from the Wieland book.
         """
         dip = - (90.0 - np.rad2deg(np.arctan(np.sqrt(2.0))))
 
-        v1 = _dip_azimuth2zse_base_vector(dip, -90.0)
-        v2 = _dip_azimuth2zse_base_vector(dip, 30.0)
-        v3 = _dip_azimuth2zse_base_vector(dip, 150.0)
+        v1 = _dip_azimuth2zne_base_vector(dip, -90.0)
+        v2 = _dip_azimuth2zne_base_vector(dip, 30.0)
+        v3 = _dip_azimuth2zne_base_vector(dip, 150.0)
 
         v1_ref = np.array([np.sqrt(2.0), 0.0, -2.0]) / np.sqrt(6.0)
-        v2_ref = np.array([np.sqrt(2.0), -np.sqrt(3.0), 1.0]) / np.sqrt(6.0)
-        v3_ref = np.array([np.sqrt(2.0), np.sqrt(3.0), 1.0]) / np.sqrt(6.0)
+        v2_ref = np.array([np.sqrt(2.0), np.sqrt(3.0), 1.0]) / np.sqrt(6.0)
+        v3_ref = np.array([np.sqrt(2.0), -np.sqrt(3.0), 1.0]) / np.sqrt(6.0)
 
         self.assertTrue(np.allclose(v1, v1_ref, rtol=1E-7, atol=1E-7))
         self.assertTrue(np.allclose(v2, v2_ref, rtol=1E-7, atol=1E-7))
@@ -210,6 +271,240 @@ def test_galperin_configuration(self):
         self.assertTrue(np.allclose(n, n_ref, rtol=1E-7, atol=1E-7))
         self.assertTrue(np.allclose(e, e_ref, rtol=1E-7, atol=1E-7))
 
+    def test_rotate2zne_against_rotate_ne_rt(self):
+        np.random.seed(123)
+        z = np.random.random(10)
+        n = np.random.random(10)
+        e = np.random.random(10)
+
+        for ba in [0.0, 14.325, 38.234, 78.1, 90.0, 136.3435, 265.4, 351.35]:
+            r, t = rotate_ne_rt(n=n, e=e, ba=ba)
+
+            # Unrotate with rotate2zne() - this should make sure the azimuth is
+            # interpreted correctly.
+            z_new, n_new, e_new = rotate2zne(z, 0, -90,
+                                             r, ba + 180, 0,
+                                             t, ba + 270, 0)
+            np.testing.assert_allclose(z_new, z)
+            np.testing.assert_allclose(n_new, n)
+            np.testing.assert_allclose(e_new, e)
+
+    def test_rotate2zne_against_ne_rt_picking_any_two_horizontal_comps(self):
+        """
+        This also tests non-orthogonal configurations to some degree.
+        """
+        np.random.seed(456)
+        z = np.random.random(10)
+        n = np.random.random(10)
+        e = np.random.random(10)
+
+        # Careful to not pick any coordinate axes.
+        for ba in [14.325, 38.234, 78.1, 136.3435, 265.4, 351.35]:
+            r, t = rotate_ne_rt(n=n, e=e, ba=ba)
+
+            _r = [r, ba + 180, 0]
+            _t = [t, ba + 270, 0]
+            _n = [n, 0, 0]
+            _e = [e, 90, 0]
+
+            # Picking any two should be enough to reconstruct n and e.
+            for a, b in itertools.permutations([_r, _t, _n, _e], 2):
+                z_new, n_new, e_new = rotate2zne(z, 0, -90,
+                                                 a[0], a[1], a[2],
+                                                 b[0], b[1], b[2])
+                np.testing.assert_allclose(z_new, z)
+                np.testing.assert_allclose(n_new, n)
+                np.testing.assert_allclose(e_new, e)
+
+    def test_rotate2zne_against_lqt(self):
+        np.random.seed(789)
+        z = np.random.random(10)
+        n = np.random.random(10)
+        e = np.random.random(10)
+
+        bas = [0.0, 14.325, 38.234, 78.1, 90.0, 136.3435, 265.4, 180.0,
+               351.35, 360.0]
+        incs = [0.0, 10.325, 32.23, 88.1, 90.0, 132.3435, 245.4, 180.0,
+                341.35, 360.0]
+
+        for ba, inc in itertools.product(bas, incs):
+            l, q, t = rotate_zne_lqt(z=z, n=n, e=e, ba=ba, inc=inc)
+
+            dip_l = (inc % 180.0) - 90.0
+            if 180 <= inc < 360:
+                dip_l *= -1.0
+
+            dip_q = ((inc + 90) % 180) - 90
+            if 0 < inc < 90 or 270 <= inc < 360:
+                dip_q *= -1.0
+
+            az_l = ba + 180.0
+            az_q = ba
+
+            # Azimuths flip depending on the incidence angle.
+            if inc > 180:
+                az_l += 180
+            if 90 < inc <= 270:
+                az_q += 180
+
+            z_new, n_new, e_new = rotate2zne(l, az_l, dip_l,
+                                             q, az_q, dip_q,
+                                             t, ba + 270, 0)
+            np.testing.assert_allclose(z_new, z)
+            np.testing.assert_allclose(n_new, n)
+            np.testing.assert_allclose(e_new, e)
+
+    def test_rotate2zne_against_lqt_different_combinations(self):
+        np.random.seed(101112)
+        z = np.random.random(10)
+        n = np.random.random(10)
+        e = np.random.random(10)
+
+        # Exclude coordinate axis.
+        bas = [14.325, 38.234, 78.1, 136.3435, 265.4, 351.35]
+        incs = [10.325, 32.23, 88.1, 132.3435, 245.4, 341.35]
+
+        success_count = 0
+        failure_count = 0
+
+        for ba, inc in itertools.product(bas, incs):
+            l, q, t = rotate_zne_lqt(z=z, n=n, e=e, ba=ba, inc=inc)
+
+            dip_l = (inc % 180.0) - 90.0
+            if 180 <= inc < 360:
+                dip_l *= -1.0
+
+            dip_q = ((inc + 90) % 180) - 90
+            if 0 < inc < 90 or 270 <= inc < 360:
+                dip_q *= -1.0
+
+            az_l = ba + 180.0
+            az_q = ba
+
+            # Azimuths flip depending on the incidence angle.
+            if inc > 180:
+                az_l += 180
+            if 90 < inc <= 270:
+                az_q += 180
+
+            _z = [z, 0, -90, "Z"]
+            _n = [n, 0, 0, "N"]
+            _e = [e, 90, 0, "E"]
+            _l = [l, az_l, dip_l, "L"]
+            _q = [q, az_q, dip_q, "Q"]
+            _t = [t, ba + 270, 0, "T"]
+
+            # Any three of them (except three horizontal ones) should be
+            # able to reconstruct ZNE.
+            for a, b, c in itertools.permutations([_l, _q, _t, _z, _n, _e], 3):
+
+                # Three horizontal components are linearly dependent, as are
+                # Z, Q, and L.
+                if a[2] == b[2] == c[2] == 0 or \
+                        set([_i[3] for _i in (a, b, c)]) == \
+                        set(["Z", "Q", "L"]):
+                    with self.assertRaises(ValueError) as err:
+                        rotate2zne(a[0], a[1], a[2],
+                                   b[0], b[1], b[2],
+                                   c[0], c[1], c[2])
+                    self.assertTrue(err.exception.args[0].startswith(
+                        "The given directions are not linearly independent, "
+                        "at least within numerical precision. Determinant of "
+                        "the base change matrix:"))
+                    failure_count += 1
+                    continue
+
+                z_new, n_new, e_new = rotate2zne(a[0], a[1], a[2],
+                                                 b[0], b[1], b[2],
+                                                 c[0], c[1], c[2])
+                np.testing.assert_allclose(z_new, z)
+                np.testing.assert_allclose(n_new, n)
+                np.testing.assert_allclose(e_new, e)
+                success_count += 1
+        # Make sure it actually tested all combinations.
+        self.assertEqual(success_count, 3888)
+        # Also the linearly dependent variants.
+        self.assertEqual(failure_count, 432)
+
+    def test_with_real_data(self):
+        # Filtered and downsampled test data with two co-located
+        # seismometers on a step table. One (UVW) in Galperin configuration,
+        # the other (XYZ) oriented 25 degree towards counter-clockwise from
+        # North.
+        #
+        # Original data can be found here:
+        # http://examples.obspy.org/step_table_galperin_and_xyz.mseed
+        #
+        # Picture of setup:
+        # http://examples.obspy.org/step_table_galperin_and_xyz.jpg
+        u = np.array([
+            -887.77005805, 7126.9690531, 48436.17065483, 138585.24660557,
+            220190.69362083, 179040.5715419, -21365.23030094,
+            -253885.25529288, -344888.20815164, -259362.36082208,
+            -117476.30748613, - 42988.81966958, -45995.43307308,
+            -57130.87444412, -30545.75344533, 16298.87665025])
+        v = np.array([
+            2.33308511e+02, 8.16259596e+03, 5.11074487e+04, 1.48229541e+05,
+            2.41322335e+05, 2.08201013e+05, 4.93732289e+03, -2.39867750e+05,
+            -3.44167596e+05, -2.66558032e+05, -1.27714987e+05, -5.54712804e+04,
+            -6.19973652e+04, -7.66740787e+04, -5.17925310e+04,
+            -4.71673443e+03])
+        w = np.array([
+            1692.48532892, 9875.6136413, 53089.61423663, 149373.52749023,
+            240009.30157128, 204362.69005767, 1212.47406863, -239380.57384624,
+            -336783.01040666, -252884.65411222, -110766.44577398,
+            -38182.18142102, -45729.92956198, -61691.87092415,
+            -38434.81993441, 6224.73096858])
+        x = np.array([
+            -1844.85832046, -1778.44974024, -2145.6117388, -4325.27560474,
+            -8278.1836905, -10842.53378841, -8565.12113951, -2024.53838011,
+            4439.22322848, 7340.96354878, 7081.65449722, 6303.91640198,
+            6549.98692684, 7223.59663617, 7133.72073748, 6068.56702479])
+        y = np.array([
+            -242.70568894, -458.3864756, -351.75925077, 2142.51669733,
+            8287.98182002, 15822.24351111, 20151.78532927, 18511.90136103,
+            12430.22438956, 5837.66044337, 1274.9580289, -1597.06115226,
+            -4331.40686142, -7529.87533286, -10544.34374306, -12656.77586305])
+        z = np.array([
+            5.79050980e+02, 1.45190734e+04, 8.85582128e+04, 2.53690907e+05,
+            4.08578800e+05, 3.45046937e+05, -8.15914926e+03, -4.26449298e+05,
+            -5.97207861e+05, -4.53464470e+05, -2.07176498e+05, -7.94526512e+04,
+            -8.95206215e+04, -1.14008287e+05, -7.05797830e+04, 1.01175730e+04])
+
+        # Component orientations.
+        u = (u, 90.0, -(90.0 - 54.7), "U")
+        v = (v, 330.0, -(90.0 - 54.7), "V")
+        w = (w, 210.0, -(90.0 - 54.7), "W")
+        x = (x, 65.0, 0.0, "X")
+        y = (y, 335.0, 0.0, "Y")
+        z = (z, 0.0, -90.0, "Z")
+
+        # Any three should result in the same ZNE.
+        success_count = 0
+        failure_count = 0
+        for a, b, c in itertools.permutations([x, y, z, u], 3):
+            # Except if "X" and "Y" are both part of it because they don't
+            # really contain any data (vertical step table).
+            if set(["X", "Y"]).issubset(set([a[-1], b[-1], c[-1]])):
+                failure_count += 1
+                continue
+
+            z_new, _, _ = rotate2zne(
+                a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2])
+            np.testing.assert_allclose(z_new, z[0], rtol=1E-5)
+            success_count += 1
+
+            # Sanity check that it fails for slightly different rotations.
+            z_new, _, _ = rotate2zne(
+                a[0], a[1] + 1.5, a[2] - 1.5,
+                b[0], b[1] - 0.7, b[2] + 1.2,
+                c[0], c[1] + 1.0, c[2] - 0.4)
+            with self.assertRaises(AssertionError):
+                np.testing.assert_allclose(z_new, z[0], rtol=1E-5)
+
+        self.assertEqual(success_count, 12)
+        self.assertEqual(failure_count, 12)
+
 
 def suite():
     return unittest.makeSuite(RotateTestCase, 'test')