Repository: qutip/qutip

Issue:
ptrace() Qobj method returns a ket if no subsystem is traced out
### Bug Description

When trying to perform a trivial partial trace (of a ket) when no subsystem is traced over (all subsystems are kept), the result is a ket.

### Code to Reproduce the Bug

```shell
from qutip import tensor,basis
psi = tensor(basis(2, 0), basis(2, 1))
print(psi.ptrace([0,1]))
```


### Code Output

```shell
Quantum object: dims = [[2, 2], [1, 1]], shape = (4, 1), type = ket
Qobj data =
[[0.]
 [1.]
 [0.]
 [0.]]
```


### Expected Behaviour

A density operator should be returned for consistency. The documentation (https://qutip.org/docs/latest/guide/guide-tensor.html) also mentions that: 

> Note that the partial trace always results in a density matrix (mixed state), regardless of whether the composite system is a pure state (described by a state vector) or a mixed state (described by a density matrix)

### Your Environment

```shell
QuTiP version: 4.7.1
```


### Additional Context

The issue seems to originate in _ptrace_dense (see https://qutip.org/docs/latest/modules/qutip/qobj.html), where the following code appears:
```
    if not dtrace:
        # If we are keeping all dimensions, no need to construct an ndarray.
        return Q.copy()
```
A projector onto the state should be returned here instead of the copy of the state.


Discussion:
Thanks for letting us know! Fortunately, this is already fix in 5.0.0 but it would good to also change this for qutip 4.7.
The problem seems pretty simple, I think I could take care of it. However, I don't have much experience in collaborative programming, in particular I don't know what the local customs are. (I know how to use git, but I'm not familiar with the layout of the library, what tests should I perform [or even add my own, since this issue wasn't noticed before 5.0.0] etc.)
Sorry, didn't mean to close the issue, just a missclick.
A possible workaround for this issue is to manually convert the resulting ket state to a density operator by taking the outer product of the state with itself.
For example:
```
from qutip import tensor,basis
psi = tensor(basis(2, 0), basis(2, 1))
rho = psi * psi.dag()
print(rho.ptrace([0,1]))
```
This should give you the expected density operator as the result of the partial trace.
Please Let me know, if I am going correct, please do assist me further on this issue

I would check if the result is indeed a ket first. That's the code I'm using at the moment as a workaround:
```
        reduced_density_matrix = state.ptrace(qubits)
        # Check if the result is a ket -- see https://github.com/qutip/qutip/issues/2129
        if reduced_density_matrix.isket:
            # Turn the ket into a density operator
            reduced_density_matrix = ket2dm(reduced_density_matrix)
```
Thank you for the acknowledgement! Should I work further upon this issue, Please let me know and Provide me further assistance.



Resolving patch:
diff --git a/qutip/qobj.py b/qutip/qobj.py
--- a/qutip/qobj.py
+++ b/qutip/qobj.py
@@ -1357,9 +1357,12 @@ def ptrace(self, sel, sparse=None):
         if sparse:
             q = Qobj()
             q.data, q.dims, _ = _ptrace(self, sel)
-            return q.tidyup() if settings.auto_tidyup else q
+            out = q.tidyup() if settings.auto_tidyup else q
         else:
-            return _ptrace_dense(self, sel)
+            out = _ptrace_dense(self, sel)
+        if isket(out):
+            out = out.proj()
+        return out
 
     def permute(self, order):
         """Permutes a composite quantum object.


Test patch:
diff --git a/qutip/tests/test_ptrace.py b/qutip/tests/test_ptrace.py
--- a/qutip/tests/test_ptrace.py
+++ b/qutip/tests/test_ptrace.py
@@ -33,7 +33,11 @@ def test_ptrace_noncompound_rand(sparse, dm):
         state = qutip.rand_ket(5)
         if dm:
             state = state.proj()
-        assert state.ptrace(0, sparse=sparse) == state
+        if state.isket:
+            target = state.proj()
+        else:
+            target = state
+        assert state.ptrace(0, sparse=sparse) == target
 
 
 @pytest.mark.parametrize('pair', list(itertools.combinations(range(3), 2)))
@@ -49,9 +53,19 @@ def test_ptrace_unsorted_selection_subset(state, sparse, pair):
     assert state_ordered == state_reversed
 
 
+def test_ptrace_ket():
+    ket_1 = qutip.rand_ket(3)
+    ket_2 = qutip.rand_ket(4)
+    ket = qutip.tensor(ket_1, ket_2)
+    assert ket.ptrace([0, 1]) == ket.proj()
+    assert ket.ptrace([0]) == ket_1.proj() * ket_2.norm()
+
+
 @pytest.mark.parametrize('permutation', list(itertools.permutations(range(3))))
 def test_ptrace_unsorted_selection_all(state, sparse, permutation):
     state_ptraced = state.ptrace(permutation, sparse=sparse)
+    if state.isket:
+        state = state.proj()
     assert state.dims == state_ptraced.dims
     assert state == state_ptraced