Repository: qutip/qutip

Issue:
Support measurments of observables with degenerate eigenvalues
Currently the `qutip.measurement.measure_observable` function always uses rank-1 projectors to determine the final state after the measurement. But for obervables with degenerate eigenvalues you might just want to project the state onto a higher-dimensional eigenspace. Eg. measuring the $Z \otimes Z$ operator on the Bell state $\ket{\psi} = 1/\sqrt{2} (\ket{00} + \ket{11})$ using qutip will return either $\ket{00}$ or $\ket{11}$ despite the state being an eigenstate of the observable.
```
import qutip as qt
import qutip.measurement as meas
import numpy as np

#Prepare Bell state
state = qt.tensor([qt.basis(2, 0)]*2) + qt.tensor([qt.basis(2, 1)]*2)
state = state.unit()

# ZZ operator
op = qt.tensor([qt.sigmaz()]*2)

result_eigenvalue, final_state = meas.measure(state, op)

#Check that the measurement didn't change the state (because it is an eigenstate of the measurement operator)
assert np.isclose(qt.fidelity(final_state, state), 1) #fails
```

I am aware that I can get my intended behaviour by explicity passing the list of projection operators to the `measure` function, but the following enhancement would be very useful:
An option `respect_degenerate_eigenvalues=False` in `qutip.measurement.measure_observable` that groups the eigenstate projectors based on eigenvalue degeneracy. In the above example the rank-2 projectors |00><00| + |11><11| and |01><01| + |10><10| would be used and the Bell state would be preseved during a ZZ measurement.


Discussion:
Hello. I'm hoping to participate in GSOC, and would like to take a look into this issue as a first contribution.

I think the `measurement_statistics_observable` function need not return a list of eigenstates of the observable. After all, the user could just call `op.eigenstates()` directly themselves. If the signature is changed to return all of the possible states immediately after measurement, this can be made the default behavior as in the following code. (I.e, without needing a `respect_degenerate_eigenvalues` option.) Density matrices will then return projectors on the corresponding degenerate eigenspaces.

```
    eigenvalues, op_eigenstates = op.eigenstates()

    unique_eigenvalues, inverted_indices = np.unique(
        eigenvalues, return_inverse=True)
    op_eigenspaces = list([] for _ in range(len(unique_eigenvalues)))
    for index, inverted_index in enumerate(inverted_indices) :
        op_eigenspaces[inverted_index].append(op_eigenstates[index])

    if state.isket:
        poststates = [ sum( (v.dag() * state) * v for v in eigenbases )
            for eigenbases in op_eigenspaces ]
        probabilities = [e.norm() ** 2 for e in poststates]
        return unique_eigenvalues, poststates, probabilities # After normalizing
    else:
        projectors = [ sum((v * v.dag()) for v in eigenbases)
            for eigenbases in op_eigenspaces ]
        probabilities = [(p * state).tr() for p in projectors]
        return unique_eigenvalues, projectors, probabilities
```

Note however, that even if this is implemented, the degeneracy of eigenspaces is determined by the precision with which their eigenvalues are calculated. E.g if two values differ by just 1e-16, they'll still be considered distinct by np.unique().
Would it make sense to add a np.round before the np.unique call? Then the user could specify the accuracy needed for two eigenvalues to be considered distinct.
Yeah, I would think so too. Most of the times the energy gap is quite large compared to calculation errors.
But I'm really a newbie, so I'm not sure what the day-to-day users need. It would be great if a contributor could pitch in if this is a good idea.
Hello! is this still open to contributions?

Is someone working on this issue? If not is it open for the contribution I am interested in working on it.
The issue is open for contribution.

@PriyanshuPansari I have a (very inefficient) code that works somewhat for this. I ran into some issues with calculating the eigenstates (see #1998) which is the reason for the many options for calculating the eigenvalues.   I paste it here for your reference:

```python
def get_unique_projectors(operator, state, tol=1e-10, sparse=True, use_qutip_eig=False,
                          is_hermitian=False, return_projector=False):
    if (return_projector is True) and (state.isket) :
        state = q.ket2dm(state)
        
    if use_qutip_eig:
        eigv_list, eigs_list = operator.eigenstates(sparse=sparse)
    else:
        op_array = operator.full()
        if is_hermitian:
            eigv_list, eigs_array_list = scipy.linalg.eigh(op_array)
        else:
            eigv_list, eigs_array_list = scipy.linalg.eig(op_array)
        eigv_list = np.array(eigv_list)
        eigs_list = []
        for eigs_array in eigs_array_list:
            eigs_list.append(q.Qobj(eigs_array))

    unique_idx = 0
    unique_idx_list = []
    this_idx_list = []
    used_idx_list = []
    for idx, eigv in enumerate(eigv_list):
        if idx not in used_idx_list:
            idx_list = list(np.isclose(eigv_list[idx], eigv_list, atol=1e-5))
            similar_indices = [i for i, x in enumerate(idx_list) if x]
            this_idx_list.append(similar_indices)
            used_idx_list += similar_indices
            
    unique_idx_list = this_idx_list
    unique_op_list = []
    probability_list = []
    unique_eigv_list = []
    for idx_list in unique_idx_list:

        projected_state = 0
        eigv = eigv_list[idx_list[0]]
        unique_eigv_list.append(eigv)
        if state.isket and (return_projector is False):
            for eigstate in [eigs_list[i] for i in idx_list]: 
                projected_state += eigstate.dag() * state * eigstate
            probability = projected_state.norm() ** 2
            norm = projected_state.norm()
            if norm <1e-6:
                norm = 1
            unique_op_list.append(projected_state/norm)
            probability_list.append(probability)
        else:
            for eigstate in [eigs_list[i] for i in idx_list]: 
                projected_state += eigstate * eigstate.dag()
            probability = (projected_state * state).tr()
            unique_op_list.append(projected_state)
            probability_list.append(probability)
    return unique_op_list, probability_list, unique_eigv_list


if __name__ == '__main__':
    op = q.tensor(q.sigmax(), q.sigmax())
    state = q.tensor(q.basis(2,0), q.basis(2,0))
    ops, prob, eigvals = get_unique_projectors(op, state, sparse=False, use_qutip_eig=True)
    print(ops, prob, eigvals)
```


Thanks @pschindler, This solved some problems I was having.

Resolving patch:
diff --git a/qutip/__init__.py b/qutip/__init__.py
--- a/qutip/__init__.py
+++ b/qutip/__init__.py
@@ -50,7 +50,7 @@
 from .partial_transpose import *
 from .continuous_variables import *
 from .distributions import *
-
+from . import measurement
 
 # utilities
 from .utilities import *
diff --git a/qutip/measurement.py b/qutip/measurement.py
--- a/qutip/measurement.py
+++ b/qutip/measurement.py
@@ -13,7 +13,7 @@
 
 import numpy as np
 
-from . import Qobj, expect, identity, tensor
+from . import Qobj, expect, identity, tensor, settings
 
 
 def _verify_input(op, state):
@@ -36,7 +36,7 @@ def _verify_input(op, state):
         raise ValueError("state must be a ket or a density matrix")
 
 
-def _measurement_statistics_povm_ket(state, ops):
+def _measurement_statistics_povm_ket(state, ops, tol=None):
     r"""
     Returns measurement statistics (resultant states and probabilities)
     for a measurements specified by a set of positive operator valued
@@ -51,6 +51,9 @@ def _measurement_statistics_povm_ket(state, ops):
       List of measurement operators :math:`M_i` (specifying a POVM such that
       :math:`E_i = M_i^\dagger M_i`).
 
+    tol : float, optional
+        Smallest value for the probabilities. Smaller probabilities will be
+        rounded to ``0``.
 
     Returns
     -------
@@ -65,19 +68,23 @@ def _measurement_statistics_povm_ket(state, ops):
     """
     probabilities = []
     collapsed_states = []
+    if tol is None:
+        tol = settings.core["atol"]
 
     for i, op in enumerate(ops):
-        p = np.absolute((state.dag() * op.dag() * op * state))
-        probabilities.append(p)
-        if p != 0:
-            collapsed_states.append((op * state) / np.sqrt(p))
+        psi = op * state
+        p = np.absolute(psi.overlap(psi))
+        if p >= tol:
+            collapsed_states.append(psi / np.sqrt(p))
+            probabilities.append(p)
         else:
             collapsed_states.append(None)
+            probabilities.append(0.)
 
     return collapsed_states, probabilities
 
 
-def _measurement_statistics_povm_dm(density_mat, ops):
+def _measurement_statistics_povm_dm(density_mat, ops, tol=None):
     r"""
     Returns measurement statistics (resultant states and probabilities)
     for a measurements specified by a set of positive operator valued
@@ -92,6 +99,10 @@ def _measurement_statistics_povm_dm(density_mat, ops):
         List of measurement operators :math:`M_i` (specifying a POVM s.t.
         :mathm:`E_i = M_i^\dagger M_i`)
 
+    tol : float, optional
+        Smallest value for the probabilities. Smaller probabilities will be
+        rounded to ``0``.
+
     Returns
     -------
     collapsed_states : list of :class:`.Qobj`
@@ -105,20 +116,24 @@ def _measurement_statistics_povm_dm(density_mat, ops):
     """
     probabilities = []
     collapsed_states = []
+    if tol is None:
+        tol = settings.core["atol"]
 
     for i, op in enumerate(ops):
         st = op * density_mat * op.dag()
         p = st.tr()
-        probabilities.append(p)
-        if p != 0:
+        if p >= tol:
             collapsed_states.append(st/p)
+            probabilities.append(p)
         else:
             collapsed_states.append(None)
+            probabilities.append(0.)
+
 
     return collapsed_states, probabilities
 
 
-def measurement_statistics_povm(state, ops):
+def measurement_statistics_povm(state, ops, tol=None):
     r"""
     Returns measurement statistics (resultant states and probabilities) for a
     measurement specified by a set of positive operator valued measurements on
@@ -137,6 +152,10 @@ def measurement_statistics_povm(state, ops):
            projectors (s.t. :math:`E_i = M_i^\dagger = M_i`)
         3. kets (transformed to projectors)
 
+    tol : float, optional
+        Smallest value for the probabilities. Smaller probabilities will be
+        rounded to ``0``. Default is qutip's core settings' ``atol``.
+
     Returns
     -------
     collapsed_states : list of :class:`.Qobj`
@@ -160,12 +179,12 @@ def measurement_statistics_povm(state, ops):
         raise ValueError("measurement operators must sum to identity")
 
     if state.isket:
-        return _measurement_statistics_povm_ket(state, ops)
+        return _measurement_statistics_povm_ket(state, ops, tol)
     else:
-        return _measurement_statistics_povm_dm(state, ops)
+        return _measurement_statistics_povm_dm(state, ops, tol)
 
 
-def measurement_statistics_observable(state, op):
+def measurement_statistics_observable(state, op, tol=None):
     """
     Return the measurement eigenvalues, eigenstates (or projectors) and
     measurement probabilities for the given state and measurement operator.
@@ -178,33 +197,57 @@ def measurement_statistics_observable(state, op):
     op : :class:`.Qobj`
         The measurement operator.
 
+    tol : float, optional
+        Smallest value for the probabilities.
+        Default is qutip's core settings' ``atol``.
+
     Returns
     -------
     eigenvalues: list of float
         The list of eigenvalues of the measurement operator.
 
-    eigenstates_or_projectors: list of :class:`.Qobj`
-        If the state was a ket, return the eigenstates of the measurement
-        operator. Otherwise return the projectors onto the eigenstates.
+    projectors: list of :class:`.Qobj`
+        Return the projectors onto the eigenstates.
 
     probabilities: list of float
         The probability of measuring the state as being in the corresponding
         eigenstate (and the measurement result being the corresponding
         eigenvalue).
     """
+    probabilities = []
+    values = []
+    projectors = []
     _verify_input(op, state)
+    if tol is None:
+        tol = settings.core["atol"]
 
     eigenvalues, eigenstates = op.eigenstates()
-    if state.isket:
-        probabilities = [abs(e.overlap(state))**2 for e in eigenstates]
-        return eigenvalues, eigenstates, probabilities
-    else:
-        projectors = [e.proj() for e in eigenstates]
-        probabilities = [expect(v, state) for v in projectors]
-        return eigenvalues, projectors, probabilities
+    # Detect groups of eigenvalues within atol of each other.
+    # A group will be [False] * N + [True]
+    groups = np.append(np.diff(eigenvalues) >= tol, True)
+
+    present_group = []
+    for i in range(len(eigenvalues)):
+        present_group.append(i)
+        if not groups[i]:
+            continue
 
+        projector = 0
+        for j in present_group:
+            projector += eigenstates[j].proj()
+        probability = expect(projector, state)
 
-def measure_observable(state, op):
+        if probability >= tol:
+            probabilities.append(probability)
+            values.append(np.mean(eigenvalues[present_group]))
+            projectors.append(projector)
+
+        present_group = []
+
+    return values, projectors, probabilities
+
+
+def measure_observable(state, op, tol=None):
     """
     Perform a measurement specified by an operator on the given state.
 
@@ -221,6 +264,10 @@ def measure_observable(state, op):
     op : :class:`.Qobj`
         The measurement operator.
 
+    tol : float, optional
+        Smallest value for the probabilities.
+        Default is qutip's core settings' ``atol``.
+
     Returns
     -------
     measured_value : float
@@ -269,19 +316,17 @@ def measure_observable(state, op):
     The measurement result is the same, but the new state is returned as a
     density matrix.
     """
-    eigenvalues, eigenstates_or_projectors, probabilities = (
-        measurement_statistics_observable(state, op))
-    i = np.random.choice(range(len(eigenvalues)), p=probabilities)
+    eigenvalues, projectors, probabilities = (
+        measurement_statistics_observable(state, op, tol))
+    i = np.random.choice(len(eigenvalues), p=probabilities)
     if state.isket:
-        eigenstates = eigenstates_or_projectors
-        state = eigenstates[i]
+        state = (projectors[i] * state) / probabilities[i]**0.5
     else:
-        projectors = eigenstates_or_projectors
         state = (projectors[i] * state * projectors[i]) / probabilities[i]
     return eigenvalues[i], state
 
 
-def measure_povm(state, ops):
+def measure_povm(state, ops, tol=None):
     r"""
     Perform a measurement specified by list of POVMs.
 
@@ -303,6 +348,10 @@ def measure_povm(state, ops):
            :math:`E_i = M_i^\dagger = M_i`)
         3. kets (transformed to projectors)
 
+    tol : float, optional
+        Smallest value for the probabilities.
+        Default is qutip's core settings' ``atol``.
+
     Returns
     -------
     index : float
@@ -311,13 +360,14 @@ def measure_povm(state, ops):
     state : :class:`.Qobj`
         The new state (a ket if a ket was given, otherwise a density matrix).
     """
-    collapsed_states, probabilities = measurement_statistics_povm(state, ops)
-    index = np.random.choice(range(len(collapsed_states)), p=probabilities)
+    collapsed_states, probabilities = (
+        measurement_statistics_povm(state, ops, tol))
+    index = np.random.choice(len(collapsed_states), p=probabilities)
     state = collapsed_states[index]
     return index, state
 
 
-def measurement_statistics(state, ops):
+def measurement_statistics(state, ops, tol=None):
     r"""
     A dispatch method that provides measurement statistics handling both
     observable style measurements and projector style measurements(POVMs and
@@ -342,14 +392,18 @@ def measurement_statistics(state, ops):
           2. projection operators if ops correspond to projectors (s.t.
              :math:`E_i = M_i^\dagger = M_i`)
           3. kets (transformed to projectors)
+
+    tol : float, optional
+        Smallest value for the probabilities.
+        Default is qutip's core settings' ``atol``.
     """
     if isinstance(ops, list):
-        return measurement_statistics_povm(state, ops)
+        return measurement_statistics_povm(state, ops, tol)
     else:
-        return measurement_statistics_observable(state, ops)
+        return measurement_statistics_observable(state, ops, tol)
 
 
-def measure(state, ops):
+def measure(state, ops, tol=None):
     r"""
     A dispatch method that provides measurement results handling both
     observable style measurements and projector style measurements (POVMs and
@@ -374,8 +428,12 @@ def measure(state, ops):
           2. projection operators if ops correspond to projectors (s.t.
              :math:`E_i = M_i^\dagger = M_i`)
           3. kets (transformed to projectors)
+
+    tol : float, optional
+        Smallest value for the probabilities.
+        Default is qutip's core settings' ``atol``.
     """
     if isinstance(ops, list):
-        return measure_povm(state, ops)
+        return measure_povm(state, ops, tol)
     else:
-        return measure_observable(state, ops)
+        return measure_observable(state, ops, tol)


Test patch:
diff --git a/qutip/tests/test_measurement.py b/qutip/tests/test_measurement.py
--- a/qutip/tests/test_measurement.py
+++ b/qutip/tests/test_measurement.py
@@ -3,8 +3,8 @@
 import pytest
 from math import sqrt
 from qutip import (
-    Qobj, basis, ket2dm, sigmax, sigmay, sigmaz, identity, tensor,
-    rand_ket,
+    Qobj, basis, ket2dm, sigmax, sigmay, sigmaz, identity, num, tensor,
+    rand_ket
 )
 from qutip.measurement import (
     measure_povm, measurement_statistics_povm, measure_observable,
@@ -70,8 +70,6 @@ def _equivalent(left, right, tol=1e-8):
 
 
 @pytest.mark.parametrize(["op", "state", "pairs", "probabilities"], [
-    pytest.param(sigmaz(), basis(2, 0), SIGMAZ, [0, 1], id="sigmaz_ket"),
-    pytest.param(sigmaz(), basis(2, 0).proj(), SIGMAZ, [0, 1], id="sigmaz_dm"),
     pytest.param(sigmax(), basis(2, 0), SIGMAX, [0.5, 0.5], id="sigmax_ket"),
     pytest.param(sigmax(), basis(2, 0).proj(), SIGMAX, [0.5, 0.5],
                  id="sigmax_dm"),
@@ -82,19 +80,24 @@ def _equivalent(left, right, tol=1e-8):
 def test_measurement_statistics_observable(op, state, pairs, probabilities):
     """ measurement_statistics_observable: observables on basis states. """
 
-    evs, ess_or_projs, probs = measurement_statistics_observable(state, op)
-    np.testing.assert_almost_equal(evs, pairs.eigenvalues)
-    if state.isket:
-        ess = ess_or_projs
-        assert len(ess) == len(pairs.eigenstates)
-        for a, b in zip(ess, pairs.eigenstates):
-            assert _equivalent(a, b)
-    else:
-        projs = ess_or_projs
-        assert len(projs) == len(pairs.projectors)
-        for a, b in zip(projs, pairs.projectors):
-            assert _equivalent(a, b)
+    evs, projs, probs = measurement_statistics_observable(state, op)
+    assert len(projs) == len(probabilities)
     np.testing.assert_almost_equal(probs, probabilities)
+    for a, b in zip(projs, pairs.projectors):
+        assert _equivalent(a, b)
+
+
+def test_measurement_statistics_observable_degenerate():
+    """ measurement_statistics_observable: observables on basis states. """
+
+    state = basis(2, 1) & (basis(2, 0) + basis(2, 1)).unit()
+    op = sigmaz() & identity(2)
+    expected_projector = num(2) & identity(2)
+    evs, projs, probs = measurement_statistics_observable(state, op)
+    assert len(probs) == 1
+    np.testing.assert_almost_equal(probs, [1.])
+    np.testing.assert_almost_equal(evs, [-1.])
+    assert _equivalent(projs[0], expected_projector)
 
 
 @pytest.mark.parametrize(["ops", "state", "final_states", "probabilities"], [