Repository: qutip/qutip

Issue:
processor.run_state and qc.run returning different states
### Bug Description

Perhaps I am not understanding how a qutip device works.

I have been following the example from https://qutip.org/docs/latest/guide/qip/qip-processor.html#qip-processor as I want to add noise to my quantum circuits.

However, I saw that using ```processor.run_state(init_state)``` returns a different state compared to ```qc.run(init_state)``` for certain circuits.

### Code to Reproduce the Bug

```shell
import numpy as np

from qutip import basis, tensor
from qutip.qip.circuit import QubitCircuit
from qutip.qip.device import LinearSpinChain


num_qubits = 3
init_state = tensor([basis(2,1) for x in range(num_qubits)])
qc = QubitCircuit(num_qubits)
qc.add_gate("CNOT", targets=1, controls=[0])

# Uses a LinearSpinChain processor to obtain the final state
processor = LinearSpinChain(num_qubits)
processor.load_circuit(qc)
processor_state = processor.run_state(init_state).states[-1].tidyup(1.0e-6)

# Obtains state by running the circuit directly
circuit_state = qc.run(init_state)

print(processor_state)
print()
print(circuit_state)
print()

# Calculates the fidelity between both states to show that states are not just differing by a phase
fidelity = np.abs((circuit_state.dag() *  processor_state)[0])

print("Fidelity:", np.abs(fidelity[0]))
```


### Code Output

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


### Expected Behaviour

By just adding an RY gate to the circuit, both methods of obtaining the state now returns the same state with unit fidelity. 


```
import numpy as np

from qutip import basis, tensor
from qutip.qip.circuit import QubitCircuit
from qutip.qip.device import LinearSpinChain


num_qubits = 3
init_state = tensor([basis(2,1) for x in range(num_qubits)])
qc = QubitCircuit(num_qubits)
qc.add_gate("RX", targets=1,arg_value = np.pi/2)
qc.add_gate("CNOT", targets=1, controls=[0])

# Uses a LinearSpinChain processor to obtain the final state
processor = LinearSpinChain(num_qubits)
processor.load_circuit(qc)
processor_state = processor.run_state(init_state).states[-1].tidyup(1.0e-6)

# Obtains state by running the circuit directly
circuit_state = qc.run(init_state)

print(processor_state)
print()
print(circuit_state)
print()

# Calculates the fidelity between both states to show that states are not just differing by a phase
fidelity = np.abs((circuit_state.dag() *  processor_state)[0])

print("Fidelity:", np.abs(fidelity[0]))

```

### Your Environment

```shell
QuTiP: Quantum Toolbox in Python
================================
Copyright (c) QuTiP team 2011 and later.
Current admin team: Alexander Pitchford, Nathan Shammah, Shahnawaz Ahmed, Neill Lambert, Eric Giguère, Boxi Li, Jake Lishman and Simon Cross.
Board members: Daniel Burgarth, Robert Johansson, Anton F. Kockum, Franco Nori and Will Zeng.
Original developers: R. J. Johansson & P. D. Nation.
Previous lead developers: Chris Granade & A. Grimsmo.
Currently developed through wide collaboration. See https://github.com/qutip for details.

QuTiP Version:      4.7.0
Numpy Version:      1.21.5
Scipy Version:      1.7.3
Cython Version:     0.29.30
Matplotlib Version: 3.5.2
Python Version:     3.7.13
Number of CPUs:     8
BLAS Info:          INTEL MKL
OPENMP Installed:   False
INTEL MKL Ext:      False
Platform Info:      Windows (AMD64)
================================================================================
Please cite QuTiP in your publication.
================================================================================
For your convenience a bibtex reference can be easily generated using `qutip.cite()`
```


### Additional Context

_No response_


Discussion:
Sorry for such a long delay... @QuantumFall  This issue somehow escaped from my email list and I never noticed it.

This is actually a situation often encountered when testing just one gate. If Hamiltonian acts trivially on the state for a few time steps, the adaptive solver may decide on a step size so large that all the reaming pulses are skipped.

Set the max step size fixes it:
```python
options = qutip.Options(max_step=0.05)
processor_state = processor.run_state(init_state, options=options).states[-1].tidyup(1.0e-6)
```

Usually, this is because the Hamiltonian is 0 at the first few steps. This example is actually slightly different. The first pulse is a non-trivial (sxsx+sysy) (swap-like). Maybe it is because it has no effect on the initial state `(0,0,0,1)`. But  `(1,0,0,0)` seems to work well.

It seems really necessary to adaptively choose a step size for the simulators.

BTW, new updates will most likely only be added to the separate package [`qutip-qip`](https://github.com/qutip/qutip-qip). Please feel free to check there.

Hi Boxi, thank you very much for your response!

Resolving patch:
diff --git a/qutip/qip/device/processor.py b/qutip/qip/device/processor.py
--- a/qutip/qip/device/processor.py
+++ b/qutip/qip/device/processor.py
@@ -12,6 +12,7 @@
 from qutip.tensor import tensor
 from qutip.mesolve import mesolve
 from qutip.mcsolve import mcsolve
+from qutip import Options
 from qutip.qip.circuit import QubitCircuit
 from qutip.qip.noise import (
     Noise, RelaxationNoise, DecoherenceNoise,
@@ -738,6 +739,11 @@ def run_state(self, init_state=None, analytical=False, states=None,
         else:
             kwargs["c_ops"] = sys_c_ops
 
+        # set the max step size as 1/2 of the smallest gate time.
+        options = kwargs.get("options", Options())
+        if options.max_step == 0.:
+            options.max_step = np.min(np.diff(self.get_full_tlist())) / 2
+        kwargs["options"] = options
         # choose solver:
         if solver == "mesolve":
             evo_result = mesolve(


Test patch:
diff --git a/qutip/tests/test_device.py b/qutip/tests/test_device.py
--- a/qutip/tests/test_device.py
+++ b/qutip/tests/test_device.py
@@ -91,7 +91,7 @@ def test_numerical_evolution(
         init_state = qutip.tensor(extra, state)
     else:
         init_state = state
-    options = qutip.Options(store_final_state=True, nsteps=50_000)
+    options = qutip.Options(store_final_state=True, nsteps=100_000)
     result = device.run_state(init_state=init_state,
                               analytical=False,
                               options=options)
@@ -138,7 +138,7 @@ def test_numerical_circuit(circuit, device_class, kwargs, schedule_mode):
         init_state = qutip.tensor(extra, state)
     else:
         init_state = state
-    options = qutip.Options(store_final_state=True, nsteps=50_000)
+    options = qutip.Options(store_final_state=True, nsteps=100_000)
     result = device.run_state(init_state=init_state,
                               analytical=False,
                               options=options)
diff --git a/qutip/tests/test_processor.py b/qutip/tests/test_processor.py
--- a/qutip/tests/test_processor.py
+++ b/qutip/tests/test_processor.py
@@ -4,7 +4,7 @@
     assert_, run_module_suite, assert_allclose, assert_equal)
 import numpy as np
 
-from qutip.qip.device.processor import Processor
+from qutip.qip.device import Processor, LinearSpinChain
 from qutip.states import basis
 from qutip.operators import sigmaz, sigmax, sigmay, identity, destroy
 from qutip.qip.operations.gates import hadamard_transform
@@ -16,6 +16,9 @@
 from qutip.qip.qubits import qubit_states
 from qutip.metrics import fidelity
 from qutip.qip.pulse import Pulse
+from qutip.qip.circuit import QubitCircuit
+
+import pytest
 
 
 class TestCircuitProcessor:
@@ -322,7 +325,35 @@ def testChooseSolver(self):
         result = proc.run_state(init_state=init_state, solver="mcsolve")
         assert_allclose(
             fidelity(result.states[-1], qubit_states(2, [0, 1, 0, 0])),
-            1, rtol=1.e-7) 
+            1, rtol=1.e-7)
+
+    def test_max_step_size(self):
+        num_qubits = 2
+        init_state = tensor([basis(2, 1), basis(2, 1)])
+        qc = QubitCircuit(2)
+
+        # ISWAP acts trivially on the initial states.
+        # If no max_step are defined,
+        # the solver will choose a step size too large
+        # such that the X gate will be skipped.
+        qc.add_gate("ISWAP", targets=[0, 1])
+        qc.add_gate("ISWAP", targets=[0, 1])
+        qc.add_gate("X", targets=[0])
+        processor = LinearSpinChain(num_qubits)
+        processor.load_circuit(qc)
+
+        # No max_step
+        final_state = processor.run_state(
+            init_state, options=Options(max_step=10000) # too large max_step
+        ).states[-1]
+        expected_state = tensor([basis(2, 0), basis(2, 1)])
+        assert pytest.approx(fidelity(final_state, expected_state), 0.001) == 0
+
+        # With default max_step
+        final_state = processor.run_state(init_state).states[-1]
+        expected_state = tensor([basis(2, 0), basis(2, 1)])
+        assert pytest.approx(fidelity(final_state, expected_state), 0.001) == 1
+
 
 if __name__ == "__main__":
     run_module_suite()