Entangling quantum gates for coupled transmon qubits

$\gdef\Op#1{\hat{#1}}$ $\gdef\op#1{\hat{#1}}$ $\gdef\init{\text{init}}$ $\gdef\tgt{\text{tgt}}$ $\gdef\Re{\operatorname{Re}}$ $\gdef\Im{\operatorname{Im}}$

datadir(names...) = joinpath(@__DIR__, names...);

This example illustrates the optimization towards a perfectly entangling two-qubit gate for a system of two transmon qubits with a shared transmission line. It goes through three progressively more advanced optimizations:

  1. The direct optimization for a $\Op{O} = \sqrt{\text{iSWAP}}$ gate with a standard square-modulus functional
  2. The optimization towards a perfect entangler using the functional developed in Goerz et al., Phys. Rev. A 91, 062307 (2015) [1]
  3. The direct maximization of of the gate concurrence

While the first example evaluates the gradient of the optimization functional analytically, the latter two are examples for the use of automatic differentiation, or more specifically semi-automatic differentiation, as developed in Goerz et al. [2]. The optimization of the gate concurrence specifically illustrates the optimization of a functional that is inherently non-analytical.

Hamiltonian and guess pulses

We will write the Hamiltonian in units of GHz (angular frequency; the factor 2π is implicit) and ns:

const GHz = 2π
const MHz = 0.001GHz
const ns = 1.0
const μs = 1000ns;

The Hamiltonian and parameters are taken from Ref. [1, Table 1].

⊗ = kron
const 𝕚 = 1im
const N = 6  # levels per transmon

using LinearAlgebra
using SparseArrays
using QuantumControl


function transmon_hamiltonian(;
    Ωre,
    Ωim,
    N=N,  # levels per transmon
    ω₁=4.380GHz,
    ω₂=4.614GHz,
    ωd=4.498GHz,
    α₁=-210MHz,
    α₂=-215MHz,
    J=-3MHz,
    λ=1.03,
    use_sparse=:auto
)
    𝟙 = SparseMatrixCSC{ComplexF64,Int64}(sparse(I, N, N))
    b̂₁ = spdiagm(1 => complex.(sqrt.(collect(1:N-1)))) ⊗ 𝟙
    b̂₂ = 𝟙 ⊗ spdiagm(1 => complex.(sqrt.(collect(1:N-1))))
    b̂₁⁺ = sparse(b̂₁')
    b̂₂⁺ = sparse(b̂₂')
    n̂₁ = sparse(b̂₁' * b̂₁)
    n̂₂ = sparse(b̂₂' * b̂₂)
    n̂₁² = sparse(n̂₁ * n̂₁)
    n̂₂² = sparse(n̂₂ * n̂₂)
    b̂₁⁺_b̂₂ = sparse(b̂₁' * b̂₂)
    b̂₁_b̂₂⁺ = sparse(b̂₁ * b̂₂')

    ω̃₁ = ω₁ - ωd
    ω̃₂ = ω₂ - ωd

    Ĥ₀ = sparse(
        (ω̃₁ - α₁ / 2) * n̂₁ +
        (α₁ / 2) * n̂₁² +
        (ω̃₂ - α₂ / 2) * n̂₂ +
        (α₂ / 2) * n̂₂² +
        J * (b̂₁⁺_b̂₂ + b̂₁_b̂₂⁺)
    )

    Ĥ₁re = (1 / 2) * (b̂₁ + b̂₁⁺ + λ * b̂₂ + λ * b̂₂⁺)
    Ĥ₁im = (𝕚 / 2) * (b̂₁⁺ - b̂₁ + λ * b̂₂⁺ - λ * b̂₂)

    if ((N < 5) && (use_sparse ≢ true)) || use_sparse ≡ false
        H = hamiltonian(Array(Ĥ₀), (Array(Ĥ₁re), Ωre), (Array(Ĥ₁im), Ωim))
    else
        H = hamiltonian(Ĥ₀, (Ĥ₁re, Ωre), (Ĥ₁im, Ωim))
    end
    return H

end;

We choose a pulse duration of 400 ns. The guess pulse amplitude is 35 MHz, with a 15 ns switch-on/-off time. This switch-on/-off must be maintained in the optimization: A pulse that does not start from or end at zero would not be physical. For GRAPE, we can achieve this by using a ShapedAmplitude:

using QuantumControl.Amplitudes: ShapedAmplitude

This allows to have a control amplitude $Ω(t) = S(t) ϵ(t)$ where $S(t)$ is a fixed shape and $ϵ(t)$ is the pulse directly tuned by the optimization. We start with a constant $ϵ(t)$ and do not place any restrictions on how the optimization might update $ϵ(t)$.

The Hamiltonian is written in a rotating frame, so in general, the control field is allowed to be complex-valued. We separate this into two control fields, one for the real part and one for the imaginary part. Initially, the imaginary part is zero, corresponding to a field exactly at the frequency of the rotating frame.

Note that passing tlist to ShapedAmplitude discretizes both the control and the shape function to the midpoints of the tlist array.

using QuantumControl.Shapes: flattop

function guess_amplitudes(; T=400ns, E₀=35MHz, dt=0.1ns, t_rise=15ns)

    tlist = collect(range(0, T, step=dt))
    shape(t) = flattop(t, T=T, t_rise=t_rise)
    Ωre = ShapedAmplitude(t -> E₀, tlist; shape)
    Ωim = ShapedAmplitude(t -> 0.0, tlist; shape)

    return tlist, Ωre, Ωim

end

tlist, Ωre_guess, Ωim_guess = guess_amplitudes();

We can visualize this:

using Plots
Plots.default(
    linewidth               = 3,
    size                    = (550, 300),
    legend                  = :right,
    foreground_color_legend = nothing,
    background_color_legend = RGBA(1, 1, 1, 0.8),
)
using QuantumControl.Controls: discretize

function plot_complex_pulse(tlist, Ω; time_unit=:ns, ampl_unit=:MHz, kwargs...)

    Ω = discretize(Ω, tlist)  # make sure Ω is defined on *points* of `tlist`

    ax1 = plot(
        tlist ./ eval(time_unit),
        abs.(Ω) ./ eval(ampl_unit);
        label="|Ω|",
        xlabel="time ($time_unit)",
        ylabel="amplitude ($ampl_unit)",
        kwargs...
    )

    ax2 = plot(
        tlist ./ eval(time_unit),
        angle.(Ω) ./ π;
        label="ϕ(Ω)",
        xlabel="time ($time_unit)",
        ylabel="phase (π)"
    )

    plot(ax1, ax2, layout=(2, 1))

end

fig = plot_complex_pulse(tlist, Array(Ωre_guess) .+ 𝕚 .* Array(Ωim_guess))

We now instantiate the Hamiltonian with these control fields:

H = transmon_hamiltonian(Ωre=Ωre_guess, Ωim=Ωim_guess);

Logical basis for two-qubit gates

For simplicity, we will be define the qubits in the bare basis, i.e. ignoring the static coupling $J$.

function ket(i::Int64; N=N)
    Ψ = zeros(ComplexF64, N)
    Ψ[i+1] = 1
    return Ψ
end

function ket(indices::Int64...; N=N)
    Ψ = ket(indices[1]; N=N)
    for i in indices[2:end]
        Ψ = Ψ ⊗ ket(i; N=N)
    end
    return Ψ
end

function ket(label::AbstractString; N=N)
    indices = [parse(Int64, digit) for digit in label]
    return ket(indices...; N=N)
end;
basis = [ket("00"), ket("01"), ket("10"), ket("11")];

Optimizing for a specific quantum gate

Our target gate is $\Op{O} = \sqrt{\text{iSWAP}}$:

SQRTISWAP = [
    1  0    0   0
    0 1/√2 𝕚/√2 0
    0 𝕚/√2 1/√2 0
    0  0    0   1
];

For each basis state, we get a target state that results from applying the gate to the basis state (you can convince yourself that this equivalent multiplying the transpose of the above gate matrix to the vector of basis states):

basis_tgt = transpose(SQRTISWAP) * basis;

The optimization aims to bring the dynamic trajectory of each basis state to the corresponding target state:

trajectories = [
    Trajectory(initial_state=Ψ, target_state=Ψtgt, generator=H) for
    (Ψ, Ψtgt) ∈ zip(basis, basis_tgt)
];

We can analyze how all of the basis states evolve under the guess controls in one go:

using QuantumPropagators: Cheby

guess_states = propagate_trajectories(trajectories, tlist; method=Cheby, use_threads=true);

The gate implemented by the guess controls is

U_guess = [basis[i] ⋅ guess_states[j] for i = 1:4, j = 1:4];

We will optimize these trajectories with a square-modulus functional

using QuantumControl.Functionals: J_T_sm

The initial value of the functional is

J_T_sm(guess_states, trajectories)
0.9156372510989683

which is the gate error

1 - (abs(tr(U_guess' * SQRTISWAP)) / 4)^2
0.9156372510989683

Now, we define the full optimization problems on top of the list of trajectories, and with the optimization functional:

problem = ControlProblem(
    trajectories,
    tlist;
    iter_stop=100,
    J_T=J_T_sm,
    check_convergence=res -> begin
        (
            (res.J_T > res.J_T_prev) &&
            (res.converged = true) &&
            (res.message = "Loss of monotonic convergence")
        )
        ((res.J_T <= 1e-3) && (res.converged = true) && (res.message = "J_T < 10⁻³"))
    end,
    prop_method=Cheby,
    use_threads=true,
)
ControlProblem with 4 trajectories and 4001 time steps
  trajectories:
    Trajectory with 36-element Vector{ComplexF64} initial state, Generator with 3 ops and 2 amplitudes, 36-element Vector{ComplexF64} target state
    Trajectory with 36-element Vector{ComplexF64} initial state, Generator with 3 ops and 2 amplitudes, 36-element Vector{ComplexF64} target state
    Trajectory with 36-element Vector{ComplexF64} initial state, Generator with 3 ops and 2 amplitudes, 36-element Vector{ComplexF64} target state
    Trajectory with 36-element Vector{ComplexF64} initial state, Generator with 3 ops and 2 amplitudes, 36-element Vector{ComplexF64} target state
  tlist: [0.0, 0.1 … 400.0]
  kwargs:
    :check_convergence => #18
    :J_T => J_T_sm
    :iter_stop => 100
    :use_threads => true
    :prop_method => QuantumPropagators.Cheby
using GRAPE
opt_result = @optimize_or_load(datadir("GRAPE_GATE_OCT.jld2"), problem; method=GRAPE)
[ Info: Set callback to store result in GRAPE_GATE_OCT.jld2 on unexpected exit.
 iter.        J_T     |∇J_T|       ΔJ_T   FG(F)    secs
     0   9.16e-01   1.41e-01        n/a    1(0)     4.7
     1   8.94e-01   2.36e-01  -2.19e-02    1(0)     0.4
     2   8.85e-01   6.54e-01  -8.30e-03    1(0)     0.3
     3   8.52e-01   4.18e-01  -3.35e-02    1(0)     0.3
     4   7.97e-01   9.12e-01  -5.47e-02    1(0)     0.3
     5   5.81e-01   1.15e+00  -2.16e-01    2(0)     0.6
     6   4.49e-01   6.34e-01  -1.32e-01    2(0)     0.6
     7   4.27e-01   4.43e-01  -2.16e-02    2(0)     0.5
     8   3.97e-01   4.53e-01  -3.06e-02    1(0)     0.3
     9   2.82e-01   1.28e+00  -1.15e-01    1(0)     0.3
    10   1.96e-01   6.70e-01  -8.51e-02    1(0)     0.3
    11   1.83e-01   8.17e-01  -1.31e-02    1(0)     0.3
    12   1.61e-01   3.98e-01  -2.23e-02    1(0)     0.3
    13   1.53e-01   2.25e-01  -7.98e-03    1(0)     0.3
    14   1.42e-01   2.21e-01  -1.08e-02    1(0)     0.2
    15   1.05e-01   5.66e-01  -3.69e-02    1(…
    21   4.14e-02   1.76e-01  -5.42e-03    1(0)     0.3
    22   3.60e-02   2.23e-01  -5.39e-03    1(0)     0.3
    23   2.70e-02   2.05e-01  -9.05e-03    1(0)     0.3
    24   2.38e-02   2.15e-01  -3.19e-03    2(0)     0.6
    25   1.99e-02   1.01e-01  -3.86e-03    1(0)     0.3
    26   1.78e-02   9.93e-02  -2.16e-03    1(0)     0.3
    27   1.51e-02   1.25e-01  -2.66e-03    1(0)     0.3
    28   1.24e-02   1.62e-01  -2.66e-03    1(0)     0.3
    29   9.81e-03   7.46e-02  -2.63e-03    1(0)     0.3
    30   8.35e-03   7.97e-02  -1.47e-03    1(0)     0.3
    31   6.36e-03   1.03e-01  -1.99e-03    1(0)     0.3
    32   4.72e-03   1.60e-01  -1.63e-03    1(0)     0.3
    33   3.52e-03   9.21e-02  -1.21e-03    1(0)     0.3
    34   2.81e-03   5.80e-02  -7.08e-04    1(0)     0.3
    35   2.26e-03   6.24e-02  -5.51e-04    1(0)     0.3
    36   1.89e-03   5.82e-02  -3.64e-04    1(0)     0.3
    37   1.23e-03   5.20e-02  -6.64e-04    1(0)     0.3
    38   6.91e-04   5.10e-02  -5.37e-04    1(0)     0.3

GRAPE Optimization Result
-------------------------
- Started at 2024-05-19T23:10:24.714
- Number of trajectories: 4
- Number of iterations: 38
- Number of pure func evals: 0
- Number of func/grad evals: 43
- Value of functional: 6.90753e-04
- Reason for termination: J_T < 10⁻³
- Ended at 2024-05-19T23:10:41.492 (16 seconds, 778 milliseconds)

We extract the optimized control field from the optimization result and plot the resulting amplitude.

The optimized_controls field of the opt_results contains the optimized controls $ϵ(t)$.

ϵ_opt = opt_result.optimized_controls[1] + 𝕚 * opt_result.optimized_controls[2];

These must still be multiplied by the static shape $S(t)$ that we set up for the guess amplitudes

Ω_opt = ϵ_opt .* discretize(Ωre_guess.shape, tlist)

fig = plot_complex_pulse(tlist, Ω_opt)

We then propagate the optimized control field to analyze the resulting quantum gate:

using QuantumControl.Controls: get_controls, substitute

opt_states = propagate_trajectories(
    substitute(
        trajectories,
        IdDict(zip(get_controls(trajectories), opt_result.optimized_controls))
    ),
    tlist;
    method=Cheby,
    use_threads=true
);

The resulting gate is

U_opt = [basis[i] ⋅ opt_states[j] for i = 1:4, j = 1:4];

and we can verify the resulting fidelity

(abs(tr(U_opt' * SQRTISWAP)) / 4)^2
0.9993092470431459

Optimizing for a general perfect entangler

We define the optimization with one trajectory for each of the four basis states:

trajectories = [Trajectory(; initial_state=Ψ, generator=H) for Ψ ∈ basis];

Note that we omit the target_state here. This is because we will be optimizing for an arbitrary perfect entangler, not for a specific quantum gate. Thus, there is no a-priori known target state to which the initial state must evolve.

The optimization is steered by the perfect entanglers distance measure $D_{PE}$, that is, the geometric distance of the quantum gate obtained from propagating the four basis states to the polyhedron of perfect entanglers in the Weyl chamber. Since the logical subspace defining the qubit is embedded in the larger Hilbert space of the transmon, there may be loss of population from the logical subspace. To counter this possibility in the optimization, we add a unitarity measure to $D_{PE}$. The two terms are added with equal weight.

using TwoQubitWeylChamber: D_PE, gate_concurrence, unitarity
using QuantumControl.Functionals: gate_functional

J_T_PE = gate_functional(D_PE; unitarity_weight=0.5);

The gate_functional routines used above converts the function D_PE that receives the gate $Û$ as a 4×4 matrix into a functional of the correct from for the QuantumControl.optimize routine, which is a function of the propagated states.

We can check that for the guess pulse, we are not implementing a perfect entangler

gate_concurrence(U_guess)
0.7773116198518494

We find that the guess pulse produces a gate in the W0* region of the Weyl chamber:

using TwoQubitWeylChamber: weyl_chamber_region
weyl_chamber_region(U_guess)
"W0*"

That is, the region of the Weyl chamber containing controlled-phase gates with a phase $> π$ (Weyl chamber coordinates $c₁ > π/2$, $c₂ < π/4$).

This in fact allows use to use the perfect entangler functional without modification: if the guess pulse were in the "W1" region of the Weyl chamber, (close to SWAP), we would have to flip its sign, or we would optimize towards the local equivalence class of the SWAP gate instead of towards the perfect of perfect entanglers. In principle, we could use a modified functional that takes the absolute square of the D_PE term, by using

J_T_PE = gate_functional(D_PE; unitarity_weight=0.5, absolute_square=true)

This would specifically optimize for the surface of the perfect entanglers functional.

The guess pulse loses about 10% of population from the logical subspace:

1 - unitarity(U_guess)
0.0907166459376968

We can also evaluate the geometric distance to the polyhedron of perfect entanglers in the Weyl chamber:

D_PE(U_guess)
0.7787454222379271

Together with the unitarity measure, this is the initial value of the optimization functional:

0.5 * D_PE(U_guess) + 0.5 * (1 - unitarity(U_guess))
0.43473103408781194
J_T_PE(guess_states, trajectories)
0.43473103408781194

For the standard functional J_T_sm used in the previous section, our GRAPE was able to automatically use an analytic implementation of the gradient. For the perfect-entanglers functional, an analytic gradient exist, but is very cumbersome to implement. Instead, we make use of semi-automatic differentiation. As shown in Goerz et al., arXiv:2205.15044, by evaluating the gradient via a chain rule in the propagated states, the dependency of the gradient on the final time functional is pushed into the boundary condition for the backward propagation, $|χ_k⟩ = -∂J_T/∂⟨ϕ_k|$. We can further exploit that J_T is an explicit function of the two-qubit gate in the computational basis and use a chain rule with respect to the elements of the two-qubit gate $U_{kk'}$. The remaining derivatives $∂J_T/∂U_{kk'}$ are then obtained via automatic differentiation. This is set up via the make_gate_chi function,

using Zygote
QuantumControl.set_default_ad_framework(Zygote)

using QuantumControl.Functionals: make_gate_chi
chi_pe = make_gate_chi(D_PE, trajectories; unitarity_weight=0.5);
[ Info: QuantumControl: Setting Zygote as the default provider for automatic differentiation. This determines the default `automatic` setting for `QuantumControl.Functionals.make_chi`, `QuantumControl.Functionals.make_gate_chi`, and `QuantumControl.Functionals.make_grad_J_a`
[ Info: make_gate_chi for J_T_U=D_PE: automatic with Zygote

where the resulting chi_pe must be passed to the optimization.

Now, we formulate the full control problem

problem = ControlProblem(
    trajectories,
    tlist;
    iter_stop=100,
    prop_method=Cheby,
    J_T=J_T_PE,
    chi=chi_pe,
    check_convergence=res -> begin
        (
            (res.J_T > res.J_T_prev) &&
            (res.converged = true) &&
            (res.message = "Loss of monotonic convergence")
        )
        (
            (res.J_T <= 1e-3) &&
            (res.converged = true) &&
            (res.message = "Found a perfect entangler")
        )
    end,
    use_threads=true,
)
ControlProblem with 4 trajectories and 4001 time steps
  trajectories:
    Trajectory with 36-element Vector{ComplexF64} initial state, Generator with 3 ops and 2 amplitudes, no target state
    Trajectory with 36-element Vector{ComplexF64} initial state, Generator with 3 ops and 2 amplitudes, no target state
    Trajectory with 36-element Vector{ComplexF64} initial state, Generator with 3 ops and 2 amplitudes, no target state
    Trajectory with 36-element Vector{ComplexF64} initial state, Generator with 3 ops and 2 amplitudes, no target state
  tlist: [0.0, 0.1 … 400.0]
  kwargs:
    :chi => zygote_gate_chi!
    :check_convergence => #26
    :J_T => J_T
    :iter_stop => 100
    :use_threads => true
    :prop_method => QuantumPropagators.Cheby

With this, we can easily find a solution to the control problem:

opt_result = @optimize_or_load(datadir("GRAPE_PE_OCT.jld2"), problem; method=GRAPE)
[ Info: Set callback to store result in GRAPE_PE_OCT.jld2 on unexpected exit.
 iter.        J_T     |∇J_T|       ΔJ_T   FG(F)    secs
     0   4.35e-01   3.92e-01        n/a    1(0)     1.8
     1   3.35e-01   7.52e-01  -9.94e-02    1(0)     0.4
     2   1.69e-01   3.19e-01  -1.67e-01    1(0)     0.4
     3   1.43e-01   2.09e-01  -2.59e-02    1(0)     0.4
     4   1.00e-01   2.24e-01  -4.24e-02    1(0)     0.3
     5   5.72e-02   2.60e-01  -4.33e-02    2(0)     0.7
     6   4.96e-02   5.41e-01  -7.51e-03    1(0)     0.3
     7   7.82e-03   1.82e-01  -4.18e-02    1(0)     0.3
     8  -4.26e-03   1.13e-01  -1.21e-02    1(0)     0.3

GRAPE Optimization Result
-------------------------
- Started at 2024-05-19T23:11:02.290
- Number of trajectories: 4
- Number of iterations: 8
- Number of pure func evals: 0
- Number of func/grad evals: 10
- Value of functional: -4.25769e-03
- Reason for termination: Found a perfect entangler
- Ended at 2024-05-19T23:11:07.204 (4 seconds, 914 milliseconds)

We extract the optimized control field from the optimization result and plot it

ϵ_opt = opt_result.optimized_controls[1] + 𝕚 * opt_result.optimized_controls[2]
Ω_opt = ϵ_opt .* discretize(Ωre_guess.shape, tlist)

fig = plot_complex_pulse(tlist, Ω_opt)

We then propagate the optimized control field to analyze the resulting quantum gate:

opt_states = propagate_trajectories(
    substitute(
        trajectories,
        IdDict(zip(get_controls(trajectories), opt_result.optimized_controls))
    ),
    tlist;
    method=Cheby,
    use_threads=true
);

U_opt = [basis[i] ⋅ opt_states[j] for i = 1:4, j = 1:4];

We find that we have achieved a perfect entangler:

gate_concurrence(U_opt)
1.0

Moreover, we have reduced the population loss to less than 4%

1 - unitarity(U_opt)
0.035261694549844846

Direct maximization of the gate concurrence

In the previous optimizations, we have optimized for a perfect entangler indirectly via a geometric function in the Weyl chamber. The entire reason that perfect entangler functional was formulated is because calculating the gate concurrence directly involves the eigenvalues of the unitary, see Kraus and Cirac [3] and Childs et al. [4], which are inherently non-analytic.

However, since we are able to obtain gradient from automatic differentiation, this is no longer an insurmountable obstacle

We can define a functional for a given gate U that combines the gate concurrence and (as above) a unitarity measure to penalize loss of population from the logical subspace:

J_T_C(U) = 0.5 * (1 - gate_concurrence(U)) + 0.5 * (1 - unitarity(U));

In the optimization, we will convert this functional to one that takes the propagated states as arguments (via the gate_functional routine). Also, as before, we have to create a matching routine for the boundary condition $|χ_k⟩ = -\frac{∂}{∂⟨ϕ_k|} J_T$ of the backward-propagation via the make_gate_chi routine.

Running this, we again are able to find a perfect entangler.

opt_result_direct = @optimize_or_load(
    datadir("GRAPE_PE_OCT_direct.jld2"),
    problem;
    method=GRAPE,
    J_T=gate_functional(J_T_C),
    chi=make_gate_chi(J_T_C, trajectories)
);
[ Info: make_gate_chi for J_T_U=J_T_C: automatic with Zygote
[ Info: Set callback to store result in GRAPE_PE_OCT_direct.jld2 on unexpected exit.
 iter.        J_T     |∇J_T|       ΔJ_T   FG(F)    secs
     0   1.57e-01   1.42e-01        n/a    1(0)     0.8
     1   1.46e-01   3.18e-01  -1.05e-02    1(0)     0.5
     2   1.30e-01   2.86e-01  -1.61e-02    1(0)     0.5
     3   8.10e-02   2.10e-01  -4.91e-02    2(0)     1.0
     4   7.66e-02   3.79e-01  -4.41e-03    1(0)     0.3
     5   4.89e-02   1.87e-01  -2.77e-02    1(0)     0.4
     6   2.64e-02   2.11e-01  -2.25e-02    1(0)     0.3
     7   7.54e-03   1.09e-01  -1.89e-02    1(0)     0.4
     8   5.86e-03   1.98e-01  -1.68e-03    1(0)     0.6
     9   3.00e-03   4.01e-02  -2.87e-03    1(0)     0.4
    10   2.71e-03   2.72e-02  -2.88e-04    1(0)     0.4
    11   2.21e-03   2.82e-02  -5.01e-04    1(0)     0.4
    12   1.42e-03   2.46e-02  -7.84e-04    1(0)     0.4
    13   3.24e-04   2.83e-02  -1.10e-03    1(0)     0.4

GRAPE Optimization Result
-------------------------
- Started at 2024-05-19T23:11:19.969
- Number of trajectories: 4
- Number of iterations: 13
- Number of pure func evals: 0
- Number of func/grad evals: 15
- Value of functional: 3.24322e-04
- Reason for termination: Found a perfect entangler
- Ended at 2024-05-19T23:11:26.738 (6 seconds, 769 milliseconds)
opt_states_direct = propagate_trajectories(
    substitute(
        trajectories,
        IdDict(zip(get_controls(trajectories), opt_result_direct.optimized_controls))
    ),
    tlist;
    method=Cheby,
    use_threads=true
);

U_opt_direct = [basis[i] ⋅ opt_states_direct[j] for i = 1:4, j = 1:4];
gate_concurrence(U_opt_direct)
1.0
1 - unitarity(U_opt_direct)
0.000648643695857154