Repository: qutip/qutip

Issue:
`tlist are not compatible` when using collapse operators in `mesolve`
Hi,
I'm trying to use `mesolve` using a sampled hamiltonian to be interpolated (_list array format_ according to [the doc](https://qutip.org/docs/latest/apidoc/functions.html?highlight=mesolve#module-qutip.mesolve)) with a `tlist` that isn't the time list `times` of the Hamiltonian coefficients, but a sublist `eval_times` of `times`. This method works fine and returns the results at `eval_times` when `c_ops=[]`, but if I specify collapse operators, I get a `tlist are not compatible` error.

Here is an example of what I'm saying.
```python
times = np.linspace(0, 1, 100)
x = np.cos(2*np.pi * times)
eval_times=times[0:-1:3]
rho = qutip.basis(2, 0).proj()
ham = qutip.QobjEvo([[qutip.sigmax(), x]], tlist=times)
qutip.mesolve(ham, rho0=rho, tlist=eval_times, c_ops=[]) # works fine
collapse = [qutip.qeye(2)] 
qutip.mesolve(ham, rho0=rho, tlist=times, c_ops=collapse) # works fine as well
qutip.mesolve(ham, rho0=rho, tlist=eval_times, c_ops=collapse) # raises `tlist are not compatible` error
```

Do you have any idea what might cause this behaviour ? I believe this might be an issue with the `mesolve` solver, since when you don't specify `c_ops`, you use `sesolve`.



Discussion:
I haven't checked, but I'd bet it's because we construct a new Liouvillian `QobjEvo` on entry to `mesolve` with collapse operators.  If so, this is definitely a bug - we should have detected that the collapse operators have no time-dependence, so don't need to overwrite the `tlist`.

As a temporary work-around, you can construct the Liouvillian directly and pass it to `mesolve`:
```python
liouvillian = qutip.liouvillian(ham, collapse)
qutip.mesolve(liouvillian, rho, eval_times)
```
which should work fine for now.
Thanks a lot ! It works indeed.

Resolving patch:
diff --git a/qutip/mesolve.py b/qutip/mesolve.py
--- a/qutip/mesolve.py
+++ b/qutip/mesolve.py
@@ -293,7 +293,17 @@ def _mesolve_QobjEvo(H, c_ops, tlist, args, opt):
     else:
         L_td = H_td
     for op in c_ops:
-        op_td = QobjEvo(op, args, tlist=tlist)
+        # We want to avoid passing tlist where it isn't necessary, to allow a
+        # Hamiltonian/Liouvillian which already _has_ time-dependence not equal
+        # to the mesolve evaluation times to be used in conjunction with
+        # time-independent c_ops.  If we _always_ pass it, it may appear to
+        # QobjEvo that there is a tlist mismatch, even though it is not used.
+        if isinstance(op, Qobj):
+            op_td = QobjEvo(op)
+        elif isinstance(op, QobjEvo):
+            op_td = QobjEvo(op, args)
+        else:
+            op_td = QobjEvo(op, args, tlist=tlist)
         if not issuper(op_td.cte):
             op_td = lindblad_dissipator(op_td)
         L_td += op_td
diff --git a/qutip/qobjevo.py b/qutip/qobjevo.py
--- a/qutip/qobjevo.py
+++ b/qutip/qobjevo.py
@@ -442,7 +442,7 @@ def __init__(self, Q_object=[], args={}, copy=True,
         self.args = args.copy()
         self.dynamics_args = []
         self.cte = None
-        self.tlist = tlist
+        self.tlist = np.asarray(tlist) if tlist is not None else None
         self.compiled = ""
         self.compiled_qobjevo = None
         self.coeff_get = None
@@ -452,13 +452,17 @@ def __init__(self, Q_object=[], args={}, copy=True,
         self.use_cython = use_cython[0]
         self.safePickle = safePickle[0]
 
+        # Attempt to determine if a 2-element list is a single, time-dependent
+        # operator, or a list with 2 possibly time-dependent elements.
         if isinstance(Q_object, list) and len(Q_object) == 2:
-            if isinstance(Q_object[0], Qobj) and not isinstance(Q_object[1],
-                                                                (Qobj, list)):
-                # The format is [Qobj, f/str]
+            try:
+                # Test if parsing succeeds on this as a single element.
+                self._td_op_type(Q_object)
                 Q_object = [Q_object]
+            except (TypeError, ValueError):
+                pass
 
-        op_type = self._td_format_check_single(Q_object, tlist)
+        op_type = self._td_format_check(Q_object)
         self.ops = []
 
         if isinstance(op_type, int):
@@ -467,7 +471,7 @@ def __init__(self, Q_object=[], args={}, copy=True,
                 self.const = True
                 self.type = "cte"
             elif op_type == 1:
-                raise Exception("The Qobj must not already be a function")
+                raise TypeError("The Qobj must not already be a function")
             elif op_type == -1:
                 pass
         else:
@@ -539,42 +543,39 @@ def __init__(self, Q_object=[], args={}, copy=True,
         if state0 is not None:
             self._dynamics_args_update(0., state0)
 
-    def _td_format_check_single(self, Q_object, tlist=None):
-        op_type = []
-
+    def _td_format_check(self, Q_object):
         if isinstance(Q_object, Qobj):
-            op_type = 0
-        elif isinstance(Q_object, (FunctionType,
-                                   BuiltinFunctionType, partial)):
-            op_type = 1
-        elif isinstance(Q_object, list):
-            if (len(Q_object) == 0):
-                op_type = -1
-            for op_k in Q_object:
-                if isinstance(op_k, Qobj):
-                    op_type.append(0)
-                elif isinstance(op_k, list):
-                    if not isinstance(op_k[0], Qobj):
-                        raise TypeError("Incorrect Q_object specification")
-                    elif len(op_k) == 2:
-                        if isinstance(op_k[1], Cubic_Spline):
-                            op_type.append(4)
-                        elif callable(op_k[1]):
-                            op_type.append(1)
-                        elif isinstance(op_k[1], str):
-                            op_type.append(2)
-                        elif isinstance(op_k[1], np.ndarray):
-                            if not isinstance(tlist, np.ndarray) or not \
-                                        len(op_k[1]) == len(tlist):
-                                raise TypeError("Time list does not match")
-                            op_type.append(3)
-                        else:
-                            raise TypeError("Incorrect Q_object specification")
-                    else:
-                        raise TypeError("Incorrect Q_object specification")
+            return 0
+        if isinstance(Q_object, (FunctionType, BuiltinFunctionType, partial)):
+            return 1
+        if isinstance(Q_object, list):
+            return [self._td_op_type(element) for element in Q_object] or -1
+        raise TypeError("Incorrect Q_object specification")
+
+    def _td_op_type(self, element):
+        if isinstance(element, Qobj):
+            return 0
+        try:
+            op, td = element
+        except (TypeError, ValueError) as exc:
+            raise TypeError("Incorrect Q_object specification") from exc
+        if (not isinstance(op, Qobj)) or isinstance(td, Qobj):
+            # Qobj is itself callable, so we need an extra check to make sure
+            # that we don't have a two-element list where both are Qobj.
+            raise TypeError("Incorrect Q_object specification")
+        if isinstance(td, Cubic_Spline):
+            out = 4
+        elif callable(td):
+            out = 1
+        elif isinstance(td, str):
+            out = 2
+        elif isinstance(td, np.ndarray):
+            if self.tlist is None or td.shape != self.tlist.shape:
+                raise ValueError("Time lists are not compatible")
+            out = 3
         else:
             raise TypeError("Incorrect Q_object specification")
-        return op_type
+        return out
 
     def _args_checks(self):
         statedims = [self.cte.dims[1],[1]]
@@ -633,7 +634,7 @@ def __call__(self, t, data=False, state=None, args={}):
         try:
             t = float(t)
         except Exception as e:
-            raise TypeError("t should be a real scalar.") from e
+            raise TypeError("Time must be a real scalar.") from e
 
         if state is not None:
             self._dynamics_args_update(t, state)
@@ -871,7 +872,7 @@ def __iadd__(self, other):
                     pass
                 elif len(other.tlist) != len(self.tlist) or \
                         other.tlist[-1] != self.tlist[-1]:
-                    raise Exception("tlist are not compatible")
+                    raise ValueError("Time lists are not compatible")
         else:
             self.cte += other
             self.dummy_cte = False
@@ -915,7 +916,8 @@ def __imul__(self, other):
             self.cte *= other
             for op in self.ops:
                 op.qobj *= other
-        elif isinstance(other, QobjEvo):
+            return self
+        if isinstance(other, QobjEvo):
             if other.const:
                 self.cte *= other.cte
                 for op in self.ops:
@@ -949,11 +951,8 @@ def __imul__(self, other):
                 self.num_obj = (len(self.ops) if
                               self.dummy_cte else len(self.ops) + 1)
             self._reset_type()
-
-        else:
-            raise TypeError("QobjEvo can only be multiplied"
-                            " with QobjEvo, Qobj or numbers")
-        return self
+            return self
+        return NotImplemented
 
     def __div__(self, other):
         if isinstance(other, (int, float, complex,
@@ -961,16 +960,14 @@ def __div__(self, other):
             res = self.copy()
             res *= other**(-1)
             return res
-        else:
-            raise TypeError('Incompatible object for division')
+        return NotImplemented
 
     def __idiv__(self, other):
         if isinstance(other, (int, float, complex,
                               np.integer, np.floating, np.complexfloating)):
             self *= other**(-1)
-        else:
-            raise TypeError('Incompatible object for division')
-        return self
+            return self
+        return NotImplemented
 
     def __truediv__(self, other):
         return self.__div__(other)
@@ -1410,14 +1407,14 @@ def expect(self, t, state, herm=False):
         expect : General-purpose expectation values.
         """
         if not isinstance(t, (int, float)):
-            raise TypeError("The time need to be a real scalar")
+            raise TypeError("Time must be a real scalar")
         if isinstance(state, Qobj):
             if self.cte.dims[1] == state.dims[0]:
                 vec = state.full().ravel("F")
             elif self.cte.dims[1] == state.dims:
                 vec = state.full().ravel("F")
             else:
-                raise Exception("Dimensions do not fit")
+                raise ValueError("Dimensions do not fit")
         elif isinstance(state, np.ndarray):
             vec = state.ravel("F")
         else:
@@ -1441,7 +1438,7 @@ def expect(self, t, state, herm=False):
                        vec.reshape((self.cte.shape[1],
                                     self.cte.shape[1])).T).trace()
         else:
-            raise Exception("The shapes do not match")
+            raise ValueError("The shapes do not match")
 
         if herm:
             return exp.real
@@ -1467,19 +1464,19 @@ def mul_vec(self, t, vec):
         """
         was_Qobj = False
         if not isinstance(t, (int, float)):
-            raise TypeError("the time need to be a real scalar")
+            raise TypeError("Time must be a real scalar")
         if isinstance(vec, Qobj):
             if self.cte.dims[1] != vec.dims[0]:
-                raise Exception("Dimensions do not fit")
+                raise ValueError("Dimensions do not fit")
             was_Qobj = True
             dims = vec.dims
             vec = vec.full().ravel()
         elif not isinstance(vec, np.ndarray):
             raise TypeError("The vector must be an array or Qobj")
         if vec.ndim != 1:
-            raise Exception("The vector must be 1d")
+            raise ValueError(f"The vector must be 1d, but is {vec.ndim}d")
         if vec.shape[0] != self.cte.shape[1]:
-            raise Exception("The length do not match")
+            raise ValueError("The lengths do not match")
 
         if self.compiled:
             out = self.compiled_qobjevo.mul_vec(t, vec)
@@ -1512,19 +1509,19 @@ def mul_mat(self, t, mat):
         """
         was_Qobj = False
         if not isinstance(t, (int, float)):
-            raise TypeError("the time need to be a real scalar")
+            raise TypeError("Time must be a real scalar")
         if isinstance(mat, Qobj):
             if self.cte.dims[1] != mat.dims[0]:
-                raise Exception("Dimensions do not fit")
+                raise ValueError("Dimensions do not fit")
             was_Qobj = True
             dims = mat.dims
             mat = mat.full()
         if not isinstance(mat, np.ndarray):
             raise TypeError("The vector must be an array or Qobj")
         if mat.ndim != 2:
-            raise Exception("The matrice must be 2d")
+            raise ValueError(f"The matrix must be 2d, but is {mat.ndim}d")
         if mat.shape[0] != self.cte.shape[1]:
-            raise Exception("The length do not match")
+            raise ValueError("The lengths do not match")
 
         if self.compiled:
             out = self.compiled_qobjevo.mul_mat(t, mat)


Test patch:
diff --git a/qutip/tests/test_mesolve.py b/qutip/tests/test_mesolve.py
--- a/qutip/tests/test_mesolve.py
+++ b/qutip/tests/test_mesolve.py
@@ -970,5 +970,37 @@ def callable_x(t, rho):
             "They should be imaginary.")
 
 
+def test_tlist_h_with_constant_c_ops():
+    """
+    Test that it's possible to mix a time-dependent Hamiltonian given as a
+    QobjEvo with interpolated coefficients with time-independent collapse
+    operators, if the solver times are not equal to the interpolation times of
+    the Hamiltonian.
+
+    See gh-1560.
+    """
+    state = basis(2, 0)
+    all_times = np.linspace(0, 1, 11)
+    few_times = np.linspace(0, 1, 3)
+    dependence = np.cos(2*np.pi * all_times)
+    hamiltonian = QobjEvo([[sigmax(), dependence]], tlist=all_times)
+    collapse = qeye(2)
+    result = mesolve(hamiltonian, state, few_times, c_ops=[collapse])
+    assert result.num_collapse == 1
+    assert len(result.states) == len(few_times)
+
+
+def test_tlist_h_with_other_tlist_c_ops_raises():
+    state = basis(2, 0)
+    all_times = np.linspace(0, 1, 11)
+    few_times = np.linspace(0, 1, 3)
+    dependence = np.cos(2*np.pi * all_times)
+    hamiltonian = QobjEvo([[sigmax(), dependence]], tlist=all_times)
+    collapse = [qeye(2), np.cos(2*np.pi * few_times)]
+    with pytest.raises(ValueError) as exc:
+        mesolve(hamiltonian, state, few_times, c_ops=[collapse])
+    assert str(exc.value) == "Time lists are not compatible"
+
+
 if __name__ == "__main__":
     run_module_suite()