State Vector and Unitary Evolution¶
We saw in Quick Start how we could implement a simulation of a Rabi Oscillation. Here we will first break down the error in the simulation and how to control this error before moving onto other ways we can propagate state vectors and unitaries.
Integration method and errors¶
This library uses the Suzuki-Trotter expansion to integrate the Schrödinger equation,
with Hamiltonians of the form
The first step we take to integrating the Schrödinger equation is approximating the Hamiltonian as constant over a period of time \(\Delta t\). The error induced by this approximation can be derived as follows: First, integrate (1),
Now recursively substituting the left-hand side of (3) into the right-hand-side we find
where we have only retained terms up to order \(\Delta t^2\). If we instead treated the Hamiltonian as constant over the time \(\Delta t\) we would find
By Maclaurin expanding \(H(t)\), we find the additive error induced by this approximation as
Thus, we can integrate the Schrödinger equation for a time \(N\Delta t\) as:
Next we use the first-order Suzuki-Trotter expansion to expand each exponential:[1]
where \(a_{nj}=a_j(n\Delta t)\) and for notational ease we set \(a_{n0}=1\).
Combining these results we find:
where
and \(\dot a_{nj}\coloneqq\dot a_j(n\Delta t)\). By making the following definitions
we can simplify the error term to
Note the error is quadratic in \(\Delta t\) but linear in \(N\). We can also view this as being linear in \(\Delta t\) and linear in total evolution time \(N\Delta t\). Additionally, by Nyquist’s theorem this asmptotic error scaling will not be achieved until the time step \(\Delta t\) is smaller than \(\frac{1}{2\Omega}\) where \(\Omega\) is the largest energy or frequency in the system. In our Rabi oscillation example \(\Omega=\max\left\{v,f\right\}\).
Finally, we can diagonalise each term in the Hamiltonian, \(H_j=U_jD_jU_j^\dagger\) where \(U_j\) is a unitary and \(D_j\) is diagonal. Substituting this diagonalised form into (9) we find
When we (15) on a state vector \(\psi(0)\),
we can see how the acceleration is achieved by diagonalising the Hamiltonians. The matrix exponential \(e^{-ia_{nj}H_j\Delta t}\) takes \(O(\dim^3)\) time where \(\dim\) is the dimension of the vector space acted upon by the Hamiltonians. Whereas, exponentiating the diagonal matrix \(-ia_{nj}D_j\Delta t\) takes \(O(\dim)\) time. The runtime is now dominated by the \(O(\dim^2)\) matrix vector multiplications.
Summary¶
Property |
Scaling |
|---|---|
Initialisation runtime |
\(O(\textrm{length}\times\dim^3)\) |
State vector integrator runtime |
\(O(N\times\textrm{length}\times\dim^2)\) |
Unitary integrator runtime |
\(O(N\times\textrm{length}\times\dim^3)\) |
Integrator error |
\(\mathcal O\left(N\Delta t^2\textrm{length}\left[\omega E+\alpha^2+E^2\right]\right)\) |
Other propagation methods¶
In Quick Start we integrated a Rabi oscillation using propagate(). Alternatively, we could use propagate()_all to collect the whole evolution of the state, \(\left(\psi(n\Delta t)\right)_{n=0}^N\), simply by replacing propagate() with propagate()_all.
Further, if we wish to propagate multiple states one solution is to wrap propagate() in a for loop:
# Each column is an initial state
initial_states = np.array([[1, 0, 1/np.sqrt(2)],
[0, 1, 1/np.sqrt(2)]],
np.complex128)
# Prepare the matrix to store the output
output_states = np.empty_like(initial_states)
# Propagate each input state
for j in range(initial_states.shape[1]):
output_states[:, j] = evolver.propagate(ctrl_amp, initial_states[:, j], dt)
However, a more concise and efficient code uses propagate_collection():
# Each column is an initial state
initial_states = np.array([[1, 0, 1/np.sqrt(2)],
[0, 1, 1/np.sqrt(2)]],
np.complex128)
# Propagate each input state
output_states = evolver.propagate_collection(ctrl_amp, initial_states, dt)
Finally, by passing the identity matrix to propagate_collection() as states we can compute the unitary evolution \(U(N\Delta t)\). However, a more efficient method is to utilise get_evolution().