3. Calculating the lever-arm matrix of a quantum dot

3.1. Requirements

3.1.1. Software components

  • QTCAD

3.1.2. Mesh file

  • qtcad/examples/practical_application/Ge_hole/meshes/refined_gedqd.msh

3.1.3. Python script

  • qtcad/examples/practical_application/Ge_hole/3-leverarm.py

3.1.4. References

3.2. Briefing

In the previous tutorial (Simulating hole states in a Ge/SiGe quantum dot using Poisson and Schrödinger solvers), we used QTCAD to compute the discrete energy levels of a germanium double quantum dot device for a fixed configuration of external gate voltages. In that example, the plunger gates were set to \(-1.5\ \mathrm{V}\).

In practice, gate voltages are often adjusted to control both confinement and tunneling. Adjusting voltages modifies the electrostatic potential landscape, which in turn shifts the energy levels of the quantum dots.

In this tutorial, we investigate how the energy levels respond to variations in the applied gate voltages. To do so, we compute the lever-arm matrix, a dimensionless parameter that quantifies the capacitive coupling between a given gate electrode and the quantum dot.

To extract the lever arm, we apply small voltage perturbations around a chosen operating point and solve the Poisson and Schrödinger equation for each configuration. By tracking the resulting shifts in the eigenenergies, we obtain a direct measure of how strongly each gate influences the quantum dot.

For a more detailed theoretical discussion of lever arms, please see Lever arm theory.

3.3. Setting up the device

3.4. Defining paths and loading the mesh

We define the simulation paths and the location of the refined double quantum dot mesh introduced in Simulating hole states in a Ge/SiGe quantum dot using Poisson and Schrödinger solvers.

script_dir = Path(__file__).parent.resolve()
mesh_dir = script_dir / "meshes"
mesh_file_dir = mesh_dir / "refined_gedqd.msh"
xao_dir = mesh_dir / "ge_dqd.xao"
out_dir = script_dir / "output"
out_dir.mkdir(exist_ok=True)

The mesh is then loaded with nanometer scaling.

scaling = 1e-9  # nanometers
mesh = Mesh(scaling, mesh_file_dir)
mesh.show()

3.4.1. Device construction

We construct a hole-based quantum dot device using a four-band Luttinger–Kohn model.

dvc = Device(mesh, conf_carriers='h', hole_kp_model="luttinger_kohn_foreman")
dvc.set_temperature(0.1)

As shown in Simulating hole states in a Ge/SiGe quantum dot using Poisson and Schrödinger solvers we assign a material to each specified region.

# Assign the material to regions
dvc.new_region("substrate", mt.SiGe_DFT)
dvc.new_region("SiGe_barrier", mt.SiGe_DFT)
dvc.new_region("Ge_well", mt.Ge)
dvc.new_region("SiGe_cap", mt.SiGe_DFT)
dvc.new_region("oxide", mt.Al2O3)

# Dot-region volumes
dvc.new_region("Ge_well.dot_region", mt.Ge)
dvc.new_region("SiGe_barrier.dot_region", mt.SiGe_DFT)
dvc.new_region("SiGe_cap.dot_region", mt.SiGe_DFT)

3.4.2. Boundary conditions

Gate electrodes are defined using fixed bias voltages applied to selected boundaries. These gates control confinement and are the parameters varied in the lever arm analysis.

# boundary conditions
Ew = mt.Ge.chi + 1.1*mt.Ge.Eg
dvc.new_gate_bnd("P1", -0.6, Ew)
dvc.new_gate_bnd("P2", -0.6, Ew)
dvc.new_gate_bnd("BC", 0.9, Ew)
dvc.new_gate_bnd("BL", 0.5, Ew)
dvc.new_gate_bnd("BR", 0.5, Ew)

3.5. Defining the quantum dot region

The dot-region, as defined in Builder workflow: Ge/SiGe double quantum dot tutorial, consists of subregions spanning the germanium layer and the adjacent silicon–germanium layers above and below, which together form the region where quantum dots and hole confinement are expected.

dot_region_list = [
    "Ge_well.dot_region",
    "SiGe_barrier.dot_region",
    "SiGe_cap.dot_region"
]

dvc.set_dot_region(dot_region_list)

This ensures that the Schrödinger equation is solved only within the region where we expect holes to be confined.

3.6. Electrostatic potential input

The precomputed electrostatic potential is loaded and used to define the confinement landscape for the quantum dot.

phi = io.load(out_dir / 'electrostatic_potential.hdf5')
dvc.set_potential(phi)

dvc.set_V_from_phi()

At this stage, the confinement potential is fully defined and ready for quantum state computation.

3.7. Solver setup

We define a reference bias point and specify the gates that will be perturbed in order to extract the lever-arm matrix.

bias_vector = np.array([-1.5, -1.5])
gate_labels = ["P1", "P2"]

Poisson and Schrödinger solver parameters are then configured to be used as inputs in the lever-arm matrix Solver.

poisson_params = PoissonSolverParams()
poisson_params.tol = 1e-3
poisson_params.maxiter = 50

schrodinger_params = SchrodingerSolverParams()
schrodinger_params.num_states = 20
schrodinger_params.tol = 1e-5  # eV

These parameters control convergence of the solvers used internally during each bias perturbation.

The lever-arm matrix solver is configured with both Poisson and Schrödinger settings. A small voltage increment is used to compute numerical derivatives of energy levels.

lam_params = LeverArmSolverParams()
lam_params.pot_solver_params = poisson_params
lam_params.schrod_solver_params = schrodinger_params

bias_inc = 1e-3

The solver evaluates how eigenenergies shift under small gate perturbations.

3.7.1. Computing the lever-arm matrix

We instantiate the lever-arm matrix solver using the device, selected gates, bias point, and dot region definition.

slv = LeverArmSolver(
    dvc=dvc,
    labels=gate_labels,
    potentials=bias_vector,
    dot_region=dot_region_list,
    solver_params=lam_params
)

lever_arm_matrix = slv.solve(bias_inc)

Internally, the solver:

  • Perturbs each gate voltage independently,

  • Resolves the Poisson and Schrödinger equations,

  • Extracts eigenenergy shifts,

  • Builds a matrix of energy responses with respect to gate voltages.

The resulting lever-arm matrix is printed for inspection.

print("Lever-arm matrix")
print(lever_arm_matrix)

This produces the following output:

Lever-arm matrix
[[-0.08900996 -0.02663815]
[-0.08894417 -0.0265729 ]
[-0.01167518 -0.09088927]
[-0.01161943 -0.09083336]
[-0.0890973  -0.02636583]
[-0.08885289 -0.02612183]
[-0.01158519 -0.09076816]
[-0.01134741 -0.09052942]
[-0.08654017 -0.02555873]
[-0.08619409 -0.02520943]
[-0.01131372 -0.08858537]
[-0.01054812 -0.08782717]
[-0.09013183 -0.02607722]
[-0.08991508 -0.02585973]
[-0.01133771 -0.09172909]
[-0.01080169 -0.0911957 ]
[-0.08826817 -0.02467261]
[-0.0879893  -0.02441622]
[-0.01020213 -0.08952861]
[-0.00858826 -0.0879848 ]]

Each row corresponds to the double quantum dot energy eigenstates ordered by increasing energy (row 0 is the ground state, row 1 is the first excited state, and so on). Each column corresponds to a gate electrode, following the ordering defined by the gate_labels argument introduced in the :ref:gate_label section, where column 0 corresponds to P1 and column 1 corresponds to P2.

Each element of the lever-arm matrix represents the lever arm between gate j and energy level i, quantifying how strongly a given gate shifts the corresponding energy level per unit voltage. Larger magnitude values indicate stronger coupling between that gate and the corresponding eigenstate.

Optionally, the matrix can be saved for further analysis.

np.save(out_dir / "lever_arm_matrix.npy", lever_arm_matrix)

3.8. Full code

__copyright__ = "Copyright 2022-2026, Nanoacademic Technologies Inc."

from qtcad.device import materials as mt
from qtcad.device import io
from qtcad.device.mesh3d import Mesh
from qtcad.device import Device
from qtcad.device.leverarm_matrix import Solver as LeverArmSolver
from qtcad.device.leverarm_matrix import SolverParams as LeverArmSolverParams
from qtcad.device.poisson import SolverParams as PoissonSolverParams
from qtcad.device.schrodinger import SolverParams as SchrodingerSolverParams
import numpy as np
from pathlib import Path

script_dir = Path(__file__).parent.resolve()
mesh_dir = script_dir / "meshes"
mesh_file_dir = mesh_dir / "refined_gedqd.msh"
xao_dir = mesh_dir / "ge_dqd.xao"
out_dir = script_dir / "output"
out_dir.mkdir(exist_ok=True)

scaling = 1e-9  # nanometers
mesh = Mesh(scaling, mesh_file_dir)
mesh.show()

dvc = Device(mesh, conf_carriers='h', hole_kp_model="luttinger_kohn_foreman")
dvc.set_temperature(0.1)

# Assign the material to regions
dvc.new_region("substrate", mt.SiGe_DFT)
dvc.new_region("SiGe_barrier", mt.SiGe_DFT)
dvc.new_region("Ge_well", mt.Ge)
dvc.new_region("SiGe_cap", mt.SiGe_DFT)
dvc.new_region("oxide", mt.Al2O3)

# Dot-region volumes
dvc.new_region("Ge_well.dot_region", mt.Ge)
dvc.new_region("SiGe_barrier.dot_region", mt.SiGe_DFT)
dvc.new_region("SiGe_cap.dot_region", mt.SiGe_DFT)

# boundary conditions
Ew = mt.Ge.chi + 1.1*mt.Ge.Eg  
dvc.new_gate_bnd("P1", -0.6, Ew)
dvc.new_gate_bnd("P2", -0.6, Ew)
dvc.new_gate_bnd("BC", 0.9, Ew)
dvc.new_gate_bnd("BL", 0.5, Ew)
dvc.new_gate_bnd("BR", 0.5, Ew)

dot_region_list = [
    "Ge_well.dot_region",
    "SiGe_barrier.dot_region",
    "SiGe_cap.dot_region"
]

dvc.set_dot_region(dot_region_list)

phi = io.load(out_dir / 'electrostatic_potential.hdf5')
dvc.set_potential(phi)

dvc.set_V_from_phi()

bias_vector = np.array([-1.5, -1.5])
gate_labels = ["P1", "P2"]

poisson_params = PoissonSolverParams()
poisson_params.tol = 1e-3
poisson_params.maxiter = 50

schrodinger_params = SchrodingerSolverParams()
schrodinger_params.num_states = 20
schrodinger_params.tol = 1e-5  # eV

lam_params = LeverArmSolverParams()
lam_params.pot_solver_params = poisson_params
lam_params.schrod_solver_params = schrodinger_params

bias_inc = 1e-3

slv = LeverArmSolver(
    dvc=dvc,
    labels=gate_labels,
    potentials=bias_vector,
    dot_region=dot_region_list,
    solver_params=lam_params
)

lever_arm_matrix = slv.solve(bias_inc)

print("Lever-arm matrix")
print(lever_arm_matrix)

np.save(out_dir / "lever_arm_matrix.npy", lever_arm_matrix)