Skip to content

Triple Quantum Dot

Authorship

The file triple_quantum_dot.gds used in this tutorial was developed by PhD student Ciara Gallagher from the Chatterjee and Andersen labs at the Qubit Research division of QuTech.

This is the recommended first read. We take a real GDS file for a triple quantum dot, crop the active region, give it a physical scale, build the layered device, solve the gate response, and look at the potential in the quantum region plane — the plane where the three dots are confined.

The whole thing is three moves:

  1. pick the part of the chip to model and set its physical scale;
  2. describe the heterostructure and gate stack above that layout;
  3. mesh the device and solve the Poisson problem.

For the logic and math behind each step — the layout workflow, the meshing, and the gate-basis solve — see the per-file API Reference.

Worked example

1. Read the GDS file and pick the source layers

First, open the layout and note the source-layer names you want to carry into the device. (The whole-chip and interactive views come from layout.extraction; here we start once the gate layers are already identified.)

from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np

from tempura import build_device, build_problem, prepare_layout, solve_gate_potentials
from tempura.electrostatics import extract_quantum_region_plane
from tempura.formatting import format_mapping_block, format_sequence_inline, format_voltage
from tempura.layout import (
    format_device_dimensions,
    format_roi_summary,
    make_aoi_bbox_from_ranges,
    plot_gate_stencil_layers,
    plot_layout_layers,
)
from tempura.plotting import (
    make_xy_emphasis_axes,
    make_standard_plane_specs,
    nearest_axis_value,
    plot_problem_regions_with_mesh,
    plot_scalar_field_on_planes,
)

selected_source_layers = ["L4D2", "L5D2"]

2. Choose the AOI and the length scale

The AOI is still in the original GDS coordinates. grid_constant_m sets what one internal Tempura unit means in meters after rasterization — here, 10 nm.

# The AOI is still expressed in the original GDS coordinate system.
aoi_bbox = make_aoi_bbox_from_ranges(
    x_range=(-2170.4, -2169.6),
    y_range=(1569.75, 1570.25),
)

# One internal XY unit will correspond to 10 nm after prepare_layout(...).
grid_constant_m = 10e-9
nm_per_unit = grid_constant_m * 1e9

From here on, lengths are in grid units

After prepare_layout(...), Tempura works in internal units, not meters. With grid_constant_m = 10e-9, one unit is 10 nm, and the AOI size, device.length, device.width, gate masks, layer thicknesses, and every resolution=[dx, dy, dz] are all in those units. The physical ROI must be an integer number of grid cells.

3. Define the vertical stack

With the lateral layout fixed, the remaining physical input is the stack, written bottom-to-top: where the QuantumRegion sits, which dielectrics separate it from the metal, and which source layers become gates.

# The stack is the physical device specification, written bottom-to-top.
stack = [
    {
        "kind": "dielectric",
        "name": "buffer",
        "permittivity": 12.9,
        "thickness": 10.0,
    },
    {
        "kind": "quantum_region",
        "name": "quantum_region",
        "permittivity": 12.9,
        "thickness": 2.0,
    },
    {
        "kind": "dielectric",
        "name": "blanket_0",
        "permittivity": 9.1,
        "thickness": 1.0,
    },
    {
        "kind": "gate",
        "name": f"gate_{selected_source_layers[0]}",
        "source_layer": selected_source_layers[0],
        "thickness": 2.0,
    },
    {
        "kind": "dielectric",
        "name": "blanket_1",
        "permittivity": 9.1,
        "thickness": 1.0,
    },
    {
        "kind": "gate",
        "name": f"gate_{selected_source_layers[1]}",
        "source_layer": selected_source_layers[1],
        "thickness": 2.0,
    },
]

A single gate entry can become several Gate objects: if its source layer rasterizes to multiple disconnected masks, Tempura emits one gate per mask and suffixes the names automatically.

4. Prepare the layout

prepare_layout(...) crops the AOI, rescales it into internal units, and rasterizes the selected source layers onto the device grid.

# This is the layout handoff into Tempura's internal grid units.
prepared = prepare_layout(
    layout_path=layout_path,
    aoi_bbox=aoi_bbox,
    size_mode="layout_size",
    grid_constant_m=grid_constant_m,
)

available_selected_source_layers = [
    layer_name
    for layer_name in selected_source_layers
    if layer_name in prepared.gate_stencils
]
print(format_sequence_inline("Selected layers", available_selected_source_layers))
print(format_roi_summary(prepared))
Selected layers: L4D2, L5D2
ROI summary:
  physical_size: (8e-07 m (0.8 um), 5e-07 m (0.5 um))
  grid_constant: 1e-08 m (0.01 um)
  rescaled_size: (80.0, 50.0)
  grid_shape: (ny=50, nx=80)
  grid_points: 4000

5. Check the layers landed in the AOI

Before building the 3D device, confirm the cropped AOI still holds the expected layers after rescaling onto the simulation grid.

figure = plot_layout_layers(
    prepared.cropped_polygons_by_layer,
    layers=available_selected_source_layers,
    title="Selected source layers in the AOI on the simulation grid",
    coordinate_scale=nm_per_unit,
    axis_unit="nm",
)
plt.show()
plt.close(figure)

png

6. Check the gate masks

This is the best moment to catch a wrong AOI, a bad scale, or an unwanted layer — before the 3D device exists. Each panel shows one source layer with all of its disconnected gates overlaid.

figure = plot_gate_stencil_layers(
    prepared.gate_stencils,
    roi_size=prepared.roi_size,
    layer_order=available_selected_source_layers,
    title="Rasterized gate masks by layer",
    coordinate_scale=nm_per_unit,
    axis_unit="nm",
    layer_thicknesses={
        layer["source_layer"]: layer["thickness"]
        for layer in stack
        if layer["kind"] == "gate"
    },
)
plt.show()
plt.close("all")

png

7. Build the device

build_device(...) combines the rasterized masks with the vertical stack and returns a finalized Device, ready for meshing.

# build_device(...) consumes the rasterized masks and finalizes the geometry.
device = build_device(prepared, stack)

print(format_device_dimensions(device, prepared))
Device summary:
  Lx: 80.0
  Ly: 50.0
  grid: 1.0
  grid_shape: (ny=50, nx=80)
  grid_points: 4000
  layers: buffer, quantum_region, blanket_0, gate_L4D2_0, gate_L4D2_1, gate_L4D2_2, gate_L4D2_3, gate_L4D2_4, gate_L4D2_5, gate_L4D2_6, gate_L4D2_7, gate_L4D2_8, gate_L4D2_9, blanket_1, gate_L5D2_0, gate_L5D2_1, gate_L5D2_2
  physical_size: (8e-07 m (0.8 um), 5e-07 m (0.5 um))
  grid_constant: 1e-08 m (0.01 um)


/tmp/ipykernel_1578/2419023549.py:2: UserWarning: gate_L4D2 expands from one stack entry into gate layers ['gate_L4D2_0', 'gate_L4D2_1', 'gate_L4D2_2', 'gate_L4D2_3', 'gate_L4D2_4', 'gate_L4D2_5', 'gate_L4D2_6', 'gate_L4D2_7', 'gate_L4D2_8', 'gate_L4D2_9'] from source_layer 'L4D2'.
  device = build_device(prepared, stack)
/tmp/ipykernel_1578/2419023549.py:2: UserWarning: gate_L5D2 expands from one stack entry into gate layers ['gate_L5D2_0', 'gate_L5D2_1', 'gate_L5D2_2'] from source_layer 'L5D2'.
  device = build_device(prepared, stack)

8. Mesh and solve

The sample is now fixed, so the rest is numerical: build_problem(...) assembles a ProblemBuilder, problem_builder.finalized() turns it into the fixed Pescado problem, and solve_gate_potentials(...) computes one basis response per gate.

# Build the pescado problem directly from the finalized per-layer resolutions.
problem_builder = build_problem(
    device,
    vacuum_scale=2.0,
    verbose=False,
)
problem = problem_builder.finalized()
region_shapes = problem.regions_shapes
gate_names = [name for name in problem.regions["dirichlet"] if name != "Boundary"]

# One source layer can expand into many concrete gates, so keep the solve
# batched instead of forcing a one-gate-at-a-time loop.
rhs_block_size = max(1, min(len(gate_names), 8))

# Solve one basis potential per gate.
basis_potentials = solve_gate_potentials(
    problem,
    rhs_block_size=rhs_block_size,
    verbose=False,
)

9. Inspect the mesh

Three cuts through the finalized mesh: a horizontal xy plane at z = 70 nm, a vertical xz plane through the device center, and a vertical yz plane through the same center line.

plane = extract_quantum_region_plane(problem, region_shapes)

plot_region_shapes = dict(region_shapes)
plot_region_shapes.pop("Boundary", None)

coordinates = np.asarray(problem.coordinates, dtype=float)
x_cut = nearest_axis_value(coordinates, 0, 0.0)
y_cut = nearest_axis_value(coordinates, 1, 0.0)
mesh_xy_z = nearest_axis_value(coordinates, 2, 7.0)
potential_xy_z = nearest_axis_value(
    coordinates,
    2,
    float(np.asarray(device.layers["quantum_region"].shape.bbox, dtype=float)[1, 2]),
)
mesh_plane_specs = make_standard_plane_specs(
    device,
    xy_title=f"XY cut at z = {mesh_xy_z * nm_per_unit:.0f} nm",
    xy_plane_z=mesh_xy_z,
    xz_title=f"XZ cut at y = {y_cut * nm_per_unit:.0f} nm",
    xz_plane_y=y_cut,
    yz_title=f"YZ cut at x = {x_cut * nm_per_unit:.0f} nm",
    yz_plane_x=x_cut,
)
potential_plane_specs = make_standard_plane_specs(
    device,
    xy_title=f"Potential on XY cut at t = {potential_xy_z * nm_per_unit:.0f} nm",
    xy_plane_z=potential_xy_z,
    xz_title=f"Potential on XZ cut at y = {y_cut * nm_per_unit:.0f} nm",
    xz_plane_y=y_cut,
    yz_title=f"Potential on YZ cut at x = {x_cut * nm_per_unit:.0f} nm",
    yz_plane_x=x_cut,
)

region_display_names = {}
for region_name in region_shapes:
    if region_name.startswith(f"gate_{selected_source_layers[0]}"):
        region_display_names[region_name] = f"{selected_source_layers[0]} gates"
    elif region_name.startswith(f"gate_{selected_source_layers[1]}"):
        region_display_names[region_name] = f"{selected_source_layers[1]} gates"

figure, axes = make_xy_emphasis_axes(
    figsize=(15.2, 11.2),
    height_ratios=(1.8, 1.0, 1.0),
)
figure, _ = plot_problem_regions_with_mesh(
    problem,
    plot_region_shapes,
    [mesh_plane_specs["XY"], mesh_plane_specs["XZ"], mesh_plane_specs["YZ"]],
    axes=axes,
    region_display_names=region_display_names,
    suptitle="Triple quantum dot mesh cuts",
    show_volume_edges=True,
    show_mesh_points=False,
    tile_edgecolors="#f3f3f3",
    coordinate_scale=nm_per_unit,
    axis_unit="nm",
)
plt.show()
plt.close(figure)

png

10. Apply a gate configuration

The payoff: because the solve is linear, any voltage configuration is a weighted sum of the basis responses — no resolve needed. Here we set every gate from the first source layer to +1 V and every gate from the second to -1 V, then read the resulting potential in the quantum region plane.

gate_voltages = {}
combined_potential = np.zeros(problem.coordinates.shape[0], dtype=float)
for gate_name in gate_names:
    if gate_name.startswith(f"gate_{selected_source_layers[0]}"):
        voltage = 1.0
    else:
        voltage = -1.0
    gate_voltages[gate_name] = voltage
    combined_potential += voltage * basis_potentials[gate_name]

fig, axes = make_xy_emphasis_axes(
    figsize=(15.2, 12.4),
    height_ratios=(1.8, 1.0, 1.0),
)
fig, axes = plot_scalar_field_on_planes(
    problem,
    combined_potential,
    [
        potential_plane_specs["XY"],
        potential_plane_specs["XZ"],
        potential_plane_specs["YZ"],
    ],
    axes=axes,
    suptitle=(
        f"Potential: {selected_source_layers[0]} = +1 V, "
        f"{selected_source_layers[1]} = -1 V"
    ),
    colorbar_label="Potential (V)",
    coordinate_scale=nm_per_unit,
    axis_unit="nm",
)
print(format_mapping_block("Gate voltages", gate_voltages, value_formatter=format_voltage))
plt.show()
plt.close(fig)
Gate voltages:
  gate_L4D2_0: +1.00 V
  gate_L4D2_1: +1.00 V
  gate_L4D2_2: +1.00 V
  gate_L4D2_3: +1.00 V
  gate_L4D2_4: +1.00 V
  gate_L4D2_5: +1.00 V
  gate_L4D2_6: +1.00 V
  gate_L4D2_7: +1.00 V
  gate_L4D2_8: +1.00 V
  gate_L4D2_9: +1.00 V
  gate_L5D2_0: -1.00 V
  gate_L5D2_1: -1.00 V
  gate_L5D2_2: -1.00 V

png