Repository: qutip/qutip

Issue:
Orthogonalization step in `eigs_csr` missing
### Bug Description

In `dev.major`, `eigs_csr` produce eigenvectors that are not orthogonal when sharing the same eigenvalue.

### Code to Reproduce the Bug

```shell
import qutip
import numpy as np
H = qutip.rand_herm([1, 1, 1, 2, 2, 2])
_, evecs = qutip.data.eigs(H.to('csr').data, 1, 1)
evecs = evecs.to_array()

for i in range(6):
    vec1 = evecs[:,i]
    for j in range(i+1, 6):
        vec2 = evecs[:,j]
        overlap = np.inner(vec1, vec2.conj())
        if not abs(overlap) < 1e-7:
            print(i, j, overlap)
```


### Code Output

```shell
0 1 (0.09845037882574664-0.5162235950159674j)
0 2 (0.26622727906522153+0.05779924588623911j)
1 2 (-0.03215914426418319+0.03471465108382346j)
3 4 (0.12577654493163992+0.29446679788486496j)
3 5 (0.06208346730638102-0.24881812976497342j)
4 5 (0.02714640795325415+0.20205107594878488j)
```


### Expected Behaviour

While the documentation does not says the eigenvectors would be orthogonal and normalized, it is expected and more practical this way.

### Your Environment

```shell
QuTiP Version:      5.0.0.dev0+0619b3b
Numpy Version:      1.22.2
Scipy Version:      1.7.3
Python Version:     3.9.7
BLAS Info:          OPENBLAS
```


### Additional Context

_No response_


Discussion:

Resolving patch:
diff --git a/qutip/core/data/eigen.py b/qutip/core/data/eigen.py
--- a/qutip/core/data/eigen.py
+++ b/qutip/core/data/eigen.py
@@ -1,6 +1,7 @@
 import numpy as np
 import scipy.linalg
 import scipy.sparse as sp
+from itertools import combinations
 
 from .dense import Dense, from_csr
 from .csr import CSR
@@ -12,13 +13,14 @@
 ]
 
 
-if settings.install["eigh_unsafe"]:
-    def _orthogonalize(vec, other):
-        cross = np.sum(np.conj(other) * vec)
-        vec -= cross * other
-        norm = np.sum(np.conj(vec) * vec)**0.5
-        vec /= norm
+def _orthogonalize(vec, other):
+    cross = np.sum(np.conj(other) * vec)
+    vec -= cross * other
+    norm = np.sum(np.conj(vec) * vec)**0.5
+    vec /= norm
+
 
+if settings.install["eigh_unsafe"]:
     def eigh(mat, eigvals=None):
         val, vec = scipy.linalg.eig(mat)
         val = np.real(val)
@@ -225,6 +227,19 @@ def eigs_csr(data, isherm=None, vecs=True, sort='low', eigvals=0,
     isherm = isherm if isherm is not None else _isherm(data)
     evals, evecs = _eigs_csr(data.as_scipy(), isherm, vecs, eigvals,
                              num_large, num_small, tol, maxiter)
+
+    if vecs and isherm:
+        i = 0
+        while i < len(evals):
+            num_degen = np.sum(np.abs(evals - evals[i]) < (2 * tol or 1e-14))
+            # orthogonalize vectors 1 .. k with respect to the first, then
+            # 2 .. k with respect to the second, and so on. Relies on both the
+            # order of each pair and the ordering of pairs returned by
+            # combinations.
+            for k, l in combinations(range(num_degen), 2):
+                _orthogonalize(evecs[:, i+l], evecs[:, i+k])
+            i += num_degen
+
     if sort == 'high':
         # Flip arrays around.
         if vecs:


Test patch:
diff --git a/qutip/tests/core/test_eigenstates.py b/qutip/tests/core/test_eigenstates.py
--- a/qutip/tests/core/test_eigenstates.py
+++ b/qutip/tests/core/test_eigenstates.py
@@ -1,6 +1,7 @@
 import pytest
 import numpy as np
 import qutip
+from itertools import combinations
 
 
 def _canonicalise_eigenvector(vec):
@@ -68,15 +69,24 @@ def test_known_eigensystem(hamiltonian, eigenvalues, eigenstates):
 
 # Specify parametrisation over a random Hamiltonian by specifying the
 # dimensions, rather than duplicating that logic.
-@pytest.fixture(params=[pytest.param([5], id="simple"),
-                        pytest.param([5, 3, 4], id="tensor")])
+@pytest.fixture(params=[pytest.param([10], id="simple"),
+                        pytest.param([5, 3, 4], id="tensor"),
+                        pytest.param([[0, 1, 1]]*3, id="degenerate")])
 def random_hamiltonian(request):
     dimensions = request.param
-    return qutip.tensor(*[qutip.rand_herm(dim) for dim in dimensions])
+    return qutip.tensor(*[qutip.rand_herm(dim_or_evecs)
+                          for dim_or_evecs in dimensions])
 
 
-def test_satisfy_eigenvalue_equation(random_hamiltonian):
-    for eigenvalue, eigenstate in zip(*random_hamiltonian.eigenstates()):
+@pytest.mark.parametrize('sparse', [True, False])
+@pytest.mark.parametrize('dtype', ['csr', 'dense'])
+def test_satisfy_eigenvalue_equation(random_hamiltonian, sparse, dtype):
+    random_hamiltonian = random_hamiltonian.to(dtype)
+    eigenvalues, eigenstates = random_hamiltonian.eigenstates(sparse=sparse)
+    for eigenvalue, eigenstate in zip(eigenvalues, eigenstates):
         np.testing.assert_allclose((random_hamiltonian * eigenstate).full(),
                                    (eigenvalue * eigenstate).full(),
                                    atol=1e-10)
+        assert eigenstate.norm() == pytest.approx(1., abs=1e-10)
+    for evec1, evec2 in combinations(eigenstates, 2):
+        assert evec1.overlap(evec2) == pytest.approx(0., abs=1e-10)