Repository: qutip/qutip

Issue:
Qobj.iscp returns false positive for non-normal operators.
If a `Qobj` instance represents a non-normal Choi matrix (that is, [A, A^T] ≠ 0), then `Qobj.eigenenergies()` can in some cases return all zeros, such that `Qobj.iscp` returns True, even though `to_choi(...).isherm` returns False. As a concrete example:

``` python
In [1]: a = create(2).dag()
In [2]: S = sprepost(a, a)
   ...: print(S)
Quantum object: dims = [[[2], [2]], [[2], [2]]], shape = [4, 4], type = super, isherm = False
Qobj data =
[[ 0.  0.  0.  0.]
 [ 0.  0.  0.  0.]
 [ 0.  1.  0.  0.]
 [ 0.  0.  0.  0.]]
In [3]: print(S.iscp, to_choi(S).isherm)
(True, False)
```



Discussion:

Resolving patch:
diff --git a/qutip/qobj.py b/qutip/qobj.py
--- a/qutip/qobj.py
+++ b/qutip/qobj.py
@@ -113,6 +113,9 @@ class Qobj(object):
     iscp : bool
         Indicates if the quantum object represents a map, and if that map is
         completely positive (CP).
+    ishp : bool
+        Indicates if the quantum object represents a map, and if that map is
+        hermicity preserving (HP).
     istp : bool
         Indicates if the quantum object represents a map, and if that map is
         trace preserving (TP).
@@ -1622,19 +1625,38 @@ def eliminate_states(self, states_inds, normalize=False):
 
         return self.extract_states(keep_indices, normalize=normalize)
 
+    @property
+    def ishp(self):
+        # FIXME: this needs to be cached in the same ways as isherm.
+        if self.type in ["super", "oper"]:
+            try:
+                J = sr.to_choi(self)
+                return J.isherm
+            except:
+                return False
+        else:
+            return False
+
     @property
     def iscp(self):
         # FIXME: this needs to be cached in the same ways as isherm.
         if self.type in ["super", "oper"]:
             try:
-                eigs = (
+                J = (
                     self
                     # We can test with either Choi or chi, since the basis
                     # transformation between them is unitary and hence
                     # preserves the CP and TP conditions.
                     if self.superrep in ('choi', 'chi')
                     else sr.to_choi(self)
-                ).eigenenergies()
+                )
+                # If J isn't hermitian, then that could indicate either
+                # that J is not normal, or is normal, but has complex eigenvalues.
+                # In either case, it makes no sense to then demand that the
+                # eigenvalues be non-negative.
+                if not J.isherm:
+                    return False
+                eigs = J.eigenenergies()
                 return all(eigs >= -settings.atol)
             except:
                 return False
@@ -1643,7 +1665,6 @@ def iscp(self):
 
     @property
     def istp(self):
-
         if self.type in ["super", "oper"]:
             try:
                 # We use the condition from John Watrous' lecture notes,


Test patch:
diff --git a/qutip/tests/test_superop_reps.py b/qutip/tests/test_superop_reps.py
--- a/qutip/tests/test_superop_reps.py
+++ b/qutip/tests/test_superop_reps.py
@@ -43,15 +43,17 @@
 from numpy.linalg import norm
 from numpy.testing import assert_, assert_almost_equal, run_module_suite, assert_equal
 
+from unittest import expectedFailure
+
 from qutip.qobj import Qobj
 from qutip.states import basis
-from qutip.operators import identity, sigmax, qeye
+from qutip.operators import identity, sigmax, qeye, create
 from qutip.qip.gates import swap
 from qutip.random_objects import rand_super, rand_super_bcsz, rand_dm_ginibre
 from qutip.tensor import super_tensor
 from qutip.superop_reps import (kraus_to_choi, to_super, to_choi, to_kraus,
                                 to_chi, to_stinespring)
-from qutip.superoperator import operator_to_vector, vector_to_operator
+from qutip.superoperator import operator_to_vector, vector_to_operator, sprepost
 
 tol = 1e-10
 
@@ -129,36 +131,75 @@ def test_ChoiPreservesSelf(self):
     def test_random_iscptp(self):
         """
         Superoperator: Randomly generated superoperators are
-        correctly reported as cptp.
+        correctly reported as CPTP and HP.
         """
         superop = rand_super()
         assert_(superop.iscptp)
+        assert_(superop.ishp)
 
     def test_known_iscptp(self):
         """
-        Superoperator: iscp, istp and iscptp known cases.
+        Superoperator: ishp, iscp, istp and iscptp known cases.
         """
-        # Check that unitaries are CPTP.
-        assert_(identity(2).iscptp)
-        assert_(sigmax().iscptp)
-
-        # The partial transpose map, whose Choi matrix is SWAP, is TP but not
-        # CP.
+        def case(qobj, shouldhp, shouldcp, shouldtp):
+            hp = qobj.ishp
+            cp = qobj.iscp
+            tp = qobj.istp
+            cptp = qobj.iscptp
+
+            shouldcptp = shouldcp and shouldtp
+
+            if (
+                hp == shouldhp and
+                cp == shouldcp and
+                tp == shouldtp and
+                cptp == shouldcptp
+            ):
+                return
+
+            fails = []
+            if hp != shouldhp:
+                fails.append(("ishp", shouldhp, hp))
+            if tp != shouldtp:
+                fails.append(("istp", shouldtp, tp))
+            if cp != shouldcp:
+                fails.append(("iscp", shouldcp, cp))
+            if cptp != shouldcptp:
+                fails.append(("iscptp", shouldcptp, cptp))
+
+            raise AssertionError("Expected {}.".format(" and ".join([
+                "{} == {} (got {})".format(fail, expected, got)
+                for fail, expected, got in fails
+            ])))
+
+        # Conjugation by a creation operator should
+        # have be CP (and hence HP), but not TP.
+        a = create(2).dag()
+        S = sprepost(a, a.dag())
+        yield case, S, True, True, False
+
+        # A single off-diagonal element should not be CP,
+        # nor even HP.
+        S = sprepost(a, a)
+        yield case, S, False, False, False
+        
+        # Check that unitaries are CPTP and HP.
+        yield case, identity(2), True, True, True
+        yield case, sigmax(), True, True, True
+
+        # The partial transpose map, whose Choi matrix is SWAP, is TP
+        # and HP but not CP (one negative eigenvalue).
         W = Qobj(swap(), type='super', superrep='choi')
-        assert_(W.istp)
-        assert_(not W.iscp)
-        assert_(not W.iscptp)
+        yield case, W, True, False, True
 
         # Subnormalized maps (representing erasure channels, for instance)
         # can be CP but not TP.
         subnorm_map = Qobj(identity(4) * 0.9, type='super', superrep='super')
-        assert_(subnorm_map.iscp)
-        assert_(not subnorm_map.istp)
-        assert_(not subnorm_map.iscptp)
+        yield case, subnorm_map, True, True, False
 
         # Check that things which aren't even operators aren't identified as
         # CPTP.
-        assert_(not (basis(2).iscptp))
+        yield case, basis(2), False, False, False
 
     def test_choi_tr(self):
         """