Repository: scipy/scipy

Issue:
[feature request] Convert complex diagonal form to real block diagonal form
In Matlab there is a function cdf2rdf (https://www.mathworks.com/help/matlab/ref/cdf2rdf.html) which converts a complex diagonal form of eigenvalues and eigenvectors to a real diagonal form.
I didn't find the similar one in scipy, and it would be quite useful for developing numerical models that are used to solve hyperbolic systems.
Any chance this could be added in the future versions?
If not, can you recommend some alternatives and/or how to implement it myself?


Discussion:
I'd be OK with adding such a function to `scipy.linalg`.

For what it's worth, here's a quick implementation.  It isn't thoroughly tested, so use at your own risk:

```
import numpy as np


def cdf2rdf(evecs, evals):
    """
    evecs : array of eigenvectors, shape (n, n)
    evals : array of eigenvalues, shape (n,)

    evecs and evals must be the eigensystem for some *real* matrix A.

    This code assumes that complex conjugate pairs in evals are adjacent.
    (E.g. it will not work correctly with evals = [1+2j, 3, 1-2j].)
    It assumes that the real eigenvalues in evals have an imaginary
    part that is exactly 0, and it assumes that any eigenvalue that has
    a nonzero imaginary part is one of a complex conjugate pair in evals.
    (E.g. if evals[0] = 1+2e-16j, then evals[1] is expected to be 1-2e-16j.)

    The function returns the real array w with shape (n, n) and a list
    r of real scalars or arrays with shape (2, 2) such that

        A @ w = w @ block_diag(*r)

    where A is the original array for which evals and evecs is the eigensystem
    data.
    """
    n = len(evals)
    u = np.zeros((n, n), dtype=np.complex128)
    blocks = []

    # Let U be the following 2x2 block.  Then
    #    inv(U) @ diag([x+yj, x-yj]) @ U = [[x, y], [-y, x]]
    # The scaling is chosen so that abs(det(U)) = 1; this is probably
    # not essential.
    s = np.sqrt(2)/2
    sj = s*1j
    U = np.array([[sj, s], [-sj, s]])

    k = 0
    while k < n:
        val = evals[k]
        if val.imag == 0:
            blocks.append(val.real)
            u[k, k] = 1
            k += 1
        else:
            # Should check here that evals[k] and evals[k+1] are, in fact,
            # complex conjugates. For now, assume that the caller has
            # given an input in the expected form.
            re = val.real
            im = val.imag
            blocks.append([[re, im], [-im, re]])
            u[k:k+2, k:k+2] = U
            k += 2
    # evals and evecs are supposed to be the eigensystem for some *real*
    # matrix A.  With that assumption, w should be real by construction.
    w = evecs.dot(u).real
    return w, blocks
```

For example,

```
In [24]: from scipy.linalg import block_diag

In [25]: np.set_printoptions(suppress=True, precision=5)

In [26]: X = np.array([[1, 2, 3], [0, 4, 5], [0, -5, 4]])

In [27]: X
Out[27]: 
array([[ 1,  2,  3],
       [ 0,  4,  5],
       [ 0, -5,  4]])

In [28]: evals, evecs = np.linalg.eig(X)

In [29]: evals
Out[29]: array([ 1.+0.j,  4.+5.j,  4.-5.j])

In [30]: w, r = cdf2rdf(evecs, evals)

In [31]: r
Out[31]: [1.0, [[4.0, 5.0000000000000009], [-5.0000000000000009, 4.0]]]

In [32]: X @ w
Out[32]: 
array([[ 1.     ,  2.39841,  2.72179],
       [ 0.     ,  3.66498,  4.58123],
       [ 0.     , -4.58123,  3.66498]])

In [33]: w @ block_diag(*r)
Out[33]: 
array([[ 1.     ,  2.39841,  2.72179],
       [ 0.     ,  3.66498,  4.58123],
       [ 0.     , -4.58123,  3.66498]])
```
@WarrenWeckesser Thank you, this seems to be exactly what I need. I'll see if I can implement it in my code and then test it on a few examples. 
OK, let me know how it works for you.  I just made a small change to the code.  It changes the scaling of the intermediate array `u`, which changes the output `w`.  This is OK because the matrix `w` is not unique.  The requirements of the output are still satisfied by the modified code: if you compute `np.linalg.inv(w) @ X @ w`, you'll get `block_diag(*r)`.

You actually don't need to walk over the diagonal. Finding whether the next one is the conjugate of the current one and being complex is sufficient I think. So you can find the indices of those and stick the 2x2 blocks to `w` which is an identity matrix initially. I'm not sure if it would make performance difference enough though.

I wrote the matrix version down a few days ago for some teaching material so here is what is being used in case others want to read the code.

![image](https://user-images.githubusercontent.com/1303842/37258653-160e6096-257b-11e8-8a89-7f0c63cb9f84.png)


Thank you both for your comments and suggestions. The code that @WarrenWeckesser gave works very well for my problem. However, I have a large number of stacked matrices that need to be transformed from complex to real block form, so iterating over them and calling cdf2rdf every time is relatively slow. Based on that code I wrote a vectorized version which works for both a single eigenstructure and stacked arrays. This is the code:

```
def cdf2rdf(L, K):
    """
    L : 1D array of eigenvalues, shape (n, ) or 2D array od stacked arrays, shape (M, n) \n
    K : 2D array of eigenvectors, shape (n, n) or 3D array od stacked arrays, shape (M, n, n)

    L, K must be the eigenstructure for some *real* matrix A 
    (obtained for example from L, K = np.linalg.eig(A)). 
    A can also represent stacked arrays.
    
    If single eigenstructure arrays are given as inputs, then the function 
    returns the real block eigenvalue array Lr with shape (n, n) and real 
    eigenvectors array Kr with shape (n, n). If stacked arrays are given as 
    inputs, then the function returns the stacked real block eigenvalue arrays 
    Lr with shape (M, n, n) and stacked real eigenvectors arrays Kr with shape 
    (M, n, n). In both cases it is true that:

    Kr @ Lr = A @ Kr

    where A is the original array for which L and K are the eigensystem data.
    """
    # number of stacked matrices and number of eigenvalues in each matrix
    if L.ndim == 1:
        stacked = False
        M, n = 1, len(L)
        L = L[np.newaxis, :]
        K = K[np.newaxis, :, :]
    else:
        stacked = True
        M, n = L.shape 

    # get indices for the first pair of complex eigenvalues
    idx_im = np.argwhere(np.iscomplex(L)) 
    mat, k = idx_im[::2, 0], idx_im[::2, 1]
    
    # complex eigenvalues to real block diagonal form
    Lmat = np.zeros((M, n, n))
    di = range(n)
    Lmat[:, di, di] = L[:, di].real
#    Lmat[mat, k, k] = L[mat, k].real (no need for this)
    Lmat[mat, k, k+1] = L[mat, k].imag
    Lmat[mat, k+1, k] = L[mat, k+1].imag
#    Lmat[mat, k+1, k+1] = L[mat, k+1].real (no need for this)
    Lr = Lmat.real
    
    # real eigenvectors for Lr
    u = np.dstack([np.eye(n, n, dtype=np.complex128)] * (M)).T
    u[mat, k, k] = 0.5j
    u[mat, k, k+1] = 0.5
    u[mat, k+1, k] = -0.5j
    u[mat, k+1, k+1] = 0.5
    Kr = (K @ u).real
    
    if not stacked:
        Lr = Lr[0, :, :]
        Kr = Kr[0, :, :]

    return Lr, Kr
```

It works about ~4x faster then iterating over a large number of eigenstructure matrices and each time calling the original rdf2cdf function.

Implementation for a single eigenstrucutre:

```
X = np.array([[1, 2, 3], [0, 4, 5], [0, -5, 4]])

X
Out[81]: 
array([[ 1,  2,  3],
       [ 0,  4,  5],
       [ 0, -5,  4]])

evals, evecs = np.linalg.eig(X)

evals
Out[83]: array([ 1.+0.j,  4.+5.j,  4.-5.j])

w, r = cdf2rdf(evals, evecs)

r
Out[85]: 
array([[ 1.        ,  0.40016337, -0.0190554 ],
       [ 0.        ,  0.64788354,  0.        ],
       [ 0.        ,  0.        ,  0.64788354]])

w
Out[86]: 
array([[ 1.,  0.,  0.],
       [ 0.,  4.,  5.],
       [ 0., -5.,  4.]])

r @ w @ np.linalg.inv(r)
Out[87]: 
array([[ 1.,  2.,  3.],
       [ 0.,  4.,  5.],
       [ 0., -5.,  4.]])
```


Well, you are not far away from making a PR if you handle the corner cases such as all real eigs and empty array inputs etc and add the tests :) 

It would live under `linalg/decomp.py` and the tests would go into `linalg/test/test_decomp.py`. Care to submit one?
Sure, I'll just need to include several checks and make a few tests, if everything works I'll make a PR.


Resolving patch:
diff --git a/scipy/linalg/__init__.py b/scipy/linalg/__init__.py
--- a/scipy/linalg/__init__.py
+++ b/scipy/linalg/__init__.py
@@ -90,6 +90,7 @@
    schur - Schur decomposition of a matrix
    rsf2csf - Real to complex Schur form
    hessenberg - Hessenberg form of a matrix
+   cdf2rdf - Complex diagonal form to real diagonal block form
 
 .. seealso::
 
diff --git a/scipy/linalg/decomp.py b/scipy/linalg/decomp.py
--- a/scipy/linalg/decomp.py
+++ b/scipy/linalg/decomp.py
@@ -16,11 +16,12 @@
 
 __all__ = ['eig', 'eigvals', 'eigh', 'eigvalsh',
            'eig_banded', 'eigvals_banded',
-           'eigh_tridiagonal', 'eigvalsh_tridiagonal', 'hessenberg']
+           'eigh_tridiagonal', 'eigvalsh_tridiagonal', 'hessenberg', 'cdf2rdf']
 
 import numpy
 from numpy import (array, isfinite, inexact, nonzero, iscomplexobj, cast,
-                   flatnonzero, conj, asarray, argsort, empty)
+                   flatnonzero, conj, asarray, argsort, empty, newaxis,
+                   argwhere, iscomplex, eye, zeros, einsum)
 # Local imports
 from scipy._lib.six import xrange
 from scipy._lib._util import _asarray_validated
@@ -1267,3 +1268,138 @@ def hessenberg(a, calc_q=False, overwrite_a=False, check_finite=True):
     q, info = orghr(a=hq, tau=tau, lo=lo, hi=hi, lwork=lwork, overwrite_a=1)
     _check_info(info, 'orghr (hessenberg)', positive=False)
     return h, q
+
+
+def cdf2rdf(w, v):
+    """
+    Converts complex eigenvalues ``w`` and eigenvectors ``v`` to real
+    eigenvalues in a block diagonal form ``wr`` and the associated real
+    eigenvectors ``vr``, such that::
+
+        vr @ wr = X @ vr
+
+    continues to hold, where ``X`` is the original array for which ``w`` and
+    ``v`` are the eigenvalues and eigenvectors.
+
+    Parameters
+    ----------
+    w : array_like
+        Complex or real eigenvalues array of size (M,) for a single matrix, or
+        size (..., M) for multiple stacked matrices
+    v : array_like
+        Complex or real eigenvectors array of size (M, M) for a single matrix,
+        or size (..., M, M) for multiple stacked matrices
+
+    Returns
+    -------
+    wr : ndarray
+        Real diagonal block form of eigenvalues, with size (M, M) for single
+        matrix, or (..., M, M) for multiple stacked matrices.
+    vr : ndarray
+        Real eigenvectors associated with ``wr``, with size (M, M) for
+        single matrix, or (..., M, M) for multiple stacked arrays.
+
+    Notes
+    -----
+    ``w``, ``v`` must be the eigenstructure for some *real* matrix ``X``.
+    For example, obtained by ``w, v = scipy.linalg.eig(X)`` or
+    ``w, v = numpy.linalg.eig(X)`` in which case ``X`` can also represent
+    stacked arrays.
+
+    Examples
+    --------
+    >>> X = np.array([[1, 2, 3], [0, 4, 5], [0, -5, 4]])
+    >>> X
+    array([[ 1,  2,  3],
+           [ 0,  4,  5],
+           [ 0, -5,  4]])
+
+    >>> from scipy import linalg
+    >>> w, v = linalg.eig(X)
+    >>> w
+    array([ 1.+0.j,  4.+5.j,  4.-5.j])
+    >>> v
+    array([[ 1.00000+0.j     , -0.01906-0.40016j, -0.01906+0.40016j],
+           [ 0.00000+0.j     ,  0.00000-0.64788j,  0.00000+0.64788j],
+           [ 0.00000+0.j     ,  0.64788+0.j     ,  0.64788-0.j     ]])
+
+    >>> wr, vr = linalg.cdf2rdf(w, v)
+    >>> wr
+    array([[ 1.,  0.,  0.],
+           [ 0.,  4.,  5.],
+           [ 0., -5.,  4.]])
+    >>> vr
+    array([[ 1.     ,  0.40016, -0.01906],
+           [ 0.     ,  0.64788,  0.     ],
+           [ 0.     ,  0.     ,  0.64788]])
+
+    >>> vr @ wr
+    array([[ 1.     ,  1.69593,  1.9246 ],
+           [ 0.     ,  2.59153,  3.23942],
+           [ 0.     , -3.23942,  2.59153]])
+    >>> X @ vr
+    array([[ 1.     ,  1.69593,  1.9246 ],
+           [ 0.     ,  2.59153,  3.23942],
+           [ 0.     , -3.23942,  2.59153]])
+    """
+    w1, v1 = _asarray_validated(w), _asarray_validated(v)
+    if w1.ndim < 1 or w1.ndim > 2:
+        raise ValueError('expected w to be a one-dimensional array or '
+                         'two-dimensional stacked array')
+    if w1.shape[-1] < 2:
+        raise ValueError('expected w array to have 2 or more elements')
+    if v1.ndim < 2 or v1.ndim > 3:
+        raise ValueError('expected v to be a two-dimensional array or '
+                         'three-dimensional stacked array')
+    if v1.shape[-2] != v1.shape[-1] or w1.shape[-1] < 2:
+        raise ValueError('expected v to be a square matrix or stacked square '
+                         'matrices: v1.shape[-2] = v1.shape[-1]')
+    if len(v1.shape) != len(w1.shape) + 1:
+        raise ValueError('expected eigenvectors array to have exactly one '
+                         'dimension more than eigenvalues array')
+    if v1.shape[-1] != w1.shape[-1]:
+        raise ValueError('expected the same number of eigenvalues as '
+                         'eigenvectors')
+
+    # number of stacked matrices and number of eigenvalues in each matrix
+    if w.ndim == 1:
+        stacked = False
+        M, n = 1, w.size
+        w = w[newaxis, :]
+        v = v[newaxis, :, :]
+    else:
+        stacked = True
+        M, n = w.shape
+
+    # get indices for each first pair of complex eigenvalues
+    idx_im = argwhere(iscomplex(w))
+
+    # check if all complex eigenvalues have conjugate pairs
+    if len(idx_im[::2, 0]) != len(idx_im[1::2, 0]):
+        raise ValueError('expected complex-conjugate pairs of eigenvalues')
+
+    # all eigenvalues to diagonal form
+    w_mat = zeros((M, n, n))
+    di = range(n)
+    w_mat[:, di, di] = w[:, di].real
+
+    # complex eigenvalues to real block diagonal form
+    mat, k = idx_im[::2, 0], idx_im[::2, 1]
+    w_mat[mat, k, k+1] = w[mat, k].imag
+    w_mat[mat, k+1, k] = w[mat, k+1].imag
+    wr = w_mat.real
+
+    # compute real eigenvectors associated with real block diagonal eigenvalues
+    u = array([eye(n, n, dtype=numpy.complex128)] * M).reshape(M, n, n)
+    u[mat, k, k] = 0.5j
+    u[mat, k, k+1] = 0.5
+    u[mat, k+1, k] = -0.5j
+    u[mat, k+1, k+1] = 0.5
+    # multipy matrices v and u (equivalent to v @ u)
+    vr = einsum('bij, bjk -> bik', v, u).real
+
+    # remove first axis for non stacked arrays
+    if not stacked:
+        wr = wr[0, :, :]
+        vr = vr[0, :, :]
+    return wr, vr


Test patch:
diff --git a/scipy/linalg/tests/test_decomp.py b/scipy/linalg/tests/test_decomp.py
--- a/scipy/linalg/tests/test_decomp.py
+++ b/scipy/linalg/tests/test_decomp.py
@@ -25,7 +25,7 @@
      schur, rsf2csf, lu_solve, lu_factor, solve, diagsvd, hessenberg, rq,
      eig_banded, eigvals_banded, eigh, eigvalsh, qr_multiply, qz, orth, ordqz,
      subspace_angles, hadamard, eigvalsh_tridiagonal, eigh_tridiagonal,
-     null_space)
+     null_space, cdf2rdf)
 from scipy.linalg.lapack import dgbtrf, dgbtrs, zgbtrf, zgbtrs, \
      dsbev, dsbevd, dsbevx, zhbevd, zhbevx
 from scipy.linalg.misc import norm
@@ -2721,3 +2721,66 @@ def test_subspace_angles():
     assert_raises(ValueError, subspace_angles, x[0], x)
     assert_raises(ValueError, subspace_angles, x, x[0])
     assert_raises(ValueError, subspace_angles, x[:-1], x)
+
+
+class TestCDF2RDF(object):
+
+    def test_single_array2x2_real(self):
+        X = np.array([[1, 2], [3, -1]])
+        w, v = np.linalg.eig(X)
+        wr, vr = cdf2rdf(w, v)
+        assert_array_almost_equal(vr.dot(wr), X.dot(vr))
+
+    def test_single_array2x2_complex(self):
+        X = np.array([[1, 2], [-2, 1]])
+        w, v = np.linalg.eig(X)
+        wr, vr = cdf2rdf(w, v)
+        assert_array_almost_equal(vr.dot(wr), X.dot(vr))
+
+    def test_single_array3x3_real(self):
+        X = np.array([[1, 2, 3], [1, 2, 3], [2, 5, 6]])
+        w, v = np.linalg.eig(X)
+        wr, vr = cdf2rdf(w, v)
+        assert_array_almost_equal(vr.dot(wr), X.dot(vr))
+
+    def test_single_array3x3_complex(self):
+        X = np.array([[1, 2, 3], [0, 4, 5], [0, -5, 4]])
+        w, v = np.linalg.eig(X)
+        wr, vr = cdf2rdf(w, v)
+        assert_array_almost_equal(vr.dot(wr), X.dot(vr))
+
+    def test_random_stacked_arrays(self):
+        N = int(1e4)
+        for M in range(2, 7):
+            X = np.random.rand(N, M, M)
+            w, v = np.linalg.eig(X)
+            wr, vr = cdf2rdf(w, v)
+            assert_array_almost_equal(np.einsum('bij, bjk -> bik', vr, wr),
+                                      np.einsum('bij, bjk -> bik', X, vr))
+
+    def test_empty_array_error(self):
+        # Check that passing an empty array raises a ValueError.
+        w, v = np.array([]), np.array([])
+        assert_raises(ValueError, cdf2rdf, w, v)
+
+    def test_not_square_error(self):
+        # Check that passing a non-square array raises a ValueError.
+        w, v = np.arange(3), np.arange(6).reshape(3,2)
+        assert_raises(ValueError, cdf2rdf, w, v)
+
+    def test_mismatch_error(self):
+        # Check that exchanging places of w and v raises ValueError.
+        X = np.array([[1, 2, 3], [0, 4, 5], [0, -5, 4]])
+        w, v = np.linalg.eig(X)
+        assert_raises(ValueError, cdf2rdf, v, w)
+
+    def test_non_associated_error(self):
+        # Check that passing non-associated eigenvactors raises a ValueError.
+        w, v = np.arange(3), np.arange(16).reshape(4,4)
+        assert_raises(ValueError, cdf2rdf, w, v)
+
+    def test_not_conjugate_pairs(self):
+        # Check that passing non-conjugate pairs raises a ValueError.
+        X = np.array([[1, 2, 3], [1, 2, 3], [2, 5, 6+1j]])
+        w, v = np.linalg.eig(X)
+        assert_raises(ValueError, cdf2rdf, w, v)