Displacement Calibration¶
Prerequisites¶
This guide assumes you have configured a DeviceSetup and created BosonicQubit objects with assigned parameters. Please see our tutorials if you need to create your setup and qubits for the first time.
You can run this notebook on real hardware in the lab. If you don't have the hardware at your disposal, you can also run the notebook "as is" using an emulated session (see below).
Before running the displacement calibration experiment, the following calibrations must have been performed:
- Memory spectroscopy: the memory cavity resonance frequency
memory_resonance_frequencymust be known (see the Memory Spectroscopy how-to guide). - Transmon spectroscopy: the steady-state ancilla transmon resonance frequency
transmon_resonance_frequency_at_n0must be known. - Transmon Rabi calibration: the steady-state transmon \(\pi\)-pulse amplitude
selective_transmon_X180_amplitudes[0]must be calibrated. - Dispersive shift: the dispersive shift
chibetween the memory cavity and the ancilla transmon must be measured.
If you are just getting started with the LabOne Q Applications Library, please don't hesitate to reach out to us at info@zhinst.com.
Background¶
In this how-to guide, you will calibrate the displacement operation on the memory cavity of a bosonic qubit using the displacement_calibration experiment workflow in the LabOne Q Applications Library.
The displacement operation¶
Applying a resonant coherent drive to the memory cavity at frequency \(f_\mathrm{memory}\) displaces the cavity state from the vacuum \(|0\rangle\) to a coherent state \(|\beta\rangle\), where \(\beta\) is a complex number whose magnitude \(|\beta|\) characterises the displacement in phase space. The mean photon number of the resulting coherent state is:
The photon number distribution of a coherent state is Poisson:
Calibration goal¶
The goal of this experiment is to find the hardware drive amplitude \(A\) that produces a displacement of \(|\beta| = 1\). This conversion factor,
is stored in the qubit parameter displacement_amp_per_unit_beta. Once calibrated, the displacement quantum operation can accept a target \(|\beta|\) (or mean photon number \(\bar{n}\)) and compute the required hardware amplitude automatically.
Measuring the photon number distribution via number-resolved spectroscopy¶
The photon number distribution is measured indirectly through the ancilla transmon using the dispersive interaction. Each photon in the memory shifts the transmon resonance frequency by the dispersive shift \(\chi\):
The experiment performs a 2D sweep over displacement amplitude \(A\) and transmon drive frequency \(f\):
- Displacement pulse — A drive tone is applied to the memory cavity at \(f_\mathrm{memory}\) with swept amplitude \(A\), preparing the cavity in a coherent state \(|\beta(A)\rangle\).
- Transmon \(\pi\) pulse — A \(\pi\) pulse is applied at swept RF frequency \(f\), calibrated for the transmon when the memory is in \(|0\rangle\).
- Readout — The ancilla transmon is read out dispersively.
When the drive frequency \(f\) coincides with \(f_\mathrm{transmon}(n) = f_\mathrm{transmon}(0) + n \cdot \chi\) for some photon number \(n\), the \(\pi\) pulse selectively inverts the transmon conditioned on the memory containing \(n\) photons, producing a peak in the readout signal. The heights of the peaks at \(n = 0, 1, 2, \ldots\) are proportional to the photon number probabilities \(P(n)\).
For each amplitude \(A\), the extracted photon number distribution is fitted to a Poisson distribution to extract the mean photon number \(\bar{n}(A)\). A linear fit of \(A\) vs. \(\sqrt{\bar{n}}\) yields displacement_amp_per_unit_beta.
Imports¶
You'll start by importing laboneq.simple, as well as the BosonicQubit and BosonicQubitOperations classes from the bosonic qubits contribution.
import copy
import numpy as np
from laboneq.simple import *
from laboneq_applications.contrib.qpu_types.bosonic_qubits import (
BosonicQubit,
BosonicQubitOperations,
)
Define your experimental setup¶
Let's define our experimental setup. We will need:
- a DeviceSetup
- one or more BosonicQubits
- a set of
BosonicQubitOperations - a QPU
Here we will be brief and mainly provide the code to obtain these objects. For full details, see the tutorials on defining your experimental setup and logbooks and data saving.
We will use 2 bosonic qubits in this guide. Change this number to match your setup.
Instrument serial numbers¶
# Replace with the serial number of your SHFQC instrument
SHFQC_SERIAL = "DEV12073"
DATASERVER_HOST = "localhost"
DATASERVER_PORT = "8004"
number_of_qubits = 2
DeviceSetup¶
The displacement calibration experiment requires signal lines for the memory cavity drive, the ancilla transmon drive, and qubit readout. Here we use a single SHFQC instrument.
Each bosonic qubit uses:
- One QA channel pair (OUTPUT + INPUT) for readout
- One SG channel for the memory cavity drive (
drive_memory) - One SG channel for all number-selective transmon drives (
drive_transmon_at_n0…drive_transmon_at_n{max_photon_number}) - Optionally, one SG channel in LF mode for the SWAP drive (
swap_drive)
If you already have a DeviceSetup, you can reuse it and skip this cell. Just make sure the signal line names match those expected by BosonicQubit.
# Maximum photon number — determines how many selective transmon signal lines are created.
MAX_PHOTON_NUMBER = 5
device_setup = DeviceSetup(uid="my_bosonic_setup")
device_setup.add_dataserver(
uid="my_bosonic_setup",
host=DATASERVER_HOST,
port=DATASERVER_PORT,
)
device_setup.add_instruments(
SHFQC(
uid="device_shfqc",
address=SHFQC_SERIAL,
device_options="SHFQC/LRT/PLUS/QC6CH/RTR",
),
)
# ── Signal lines for qubit q0 ─────────────────────────────────────────
device_setup.add_connections(
"device_shfqc",
# Readout (QA channel 0)
create_connection(to_signal="q0/measure", ports="QACHANNELS/0/OUTPUT"),
create_connection(to_signal="q0/acquire", ports="QACHANNELS/0/INPUT"),
# Memory cavity drive (SG channel 0)
create_connection(to_signal="q0/drive_memory", ports="SGCHANNELS/0/OUTPUT"),
# SWAP drive — optional, used for Fock-state preparation (SG channel 4)
create_connection(to_signal="q0/swap_drive", ports="SGCHANNELS/4/OUTPUT"),
)
# Photon-number-selective transmon drive lines for q0 (all share SG channel 2)
for n in range(MAX_PHOTON_NUMBER + 1):
device_setup.add_connections(
"device_shfqc",
create_connection(
to_signal=f"q0/drive_transmon_at_n{n}",
ports="SGCHANNELS/2/OUTPUT",
),
)
# ── Signal lines for qubit q1 (optional) ─────────────────────────────
if number_of_qubits == 2:
device_setup.add_connections(
"device_shfqc",
create_connection(to_signal="q1/measure", ports="QACHANNELS/0/OUTPUT"),
create_connection(to_signal="q1/acquire", ports="QACHANNELS/0/INPUT"),
create_connection(to_signal="q1/drive_memory", ports="SGCHANNELS/1/OUTPUT"),
create_connection(to_signal="q1/swap_drive", ports="SGCHANNELS/5/OUTPUT"),
)
for n in range(MAX_PHOTON_NUMBER + 1):
device_setup.add_connections(
"device_shfqc",
create_connection(
to_signal=f"q1/drive_transmon_at_n{n}",
ports="SGCHANNELS/3/OUTPUT",
),
)
BosonicQubits¶
We will create BosonicQubit objects from the logical signal groups in our DeviceSetup and assign qubit parameters. The qubit UID will match the name of the logical signal group (q0, q1, …).
Adjust the parameter values to reflect the properties of your device. The parameters relevant to displacement calibration are highlighted in the comments.
# Qubit parameters — adjust these to match your device.
# Parameters marked (*) are directly relevant to the displacement calibration experiment.
QUBIT_PARAMS = {
# ── Memory cavity ────────────────────────────────────────────────
"memory_lo_frequency": 5.8e9, # LO for memory cavity drive
"memory_resonance_frequency": 6.0e9, # (*) Memory resonance frequency (set from memory spectroscopy)
"memory_drive_amplitude": 0.5,
"memory_drive_length": 0.1e-6, # (*) Duration of the displacement pulse
"memory_drive_pulse": {"function": "gaussian", "sigma": 0.25}, # (*) Pulse shape
"memory_spectroscopy_amplitude": 0.5,
"memory_spectroscopy_length": 4e-6,
"memory_drive_range": 10,
# ── Ancilla transmon ─────────────────────────────────────────────
"transmon_lo_frequency": 4.8e9,
"transmon_resonance_frequency_at_n0": 5.0e9, # (*) Transmon freq when memory in |0>
"transmon_drive_range": 10,
# ── Selective transmon Rx drives (*) ─────────────────────────────
# These are used for the pi pulse that probes the photon number state.
"max_photon_number": MAX_PHOTON_NUMBER,
"selective_transmon_Rx_length": 0.08e-6,
"selective_transmon_Rx_pulse": {"function": "gaussian", "sigma": 0.25},
"selective_transmon_X180_amplitudes": [0.8]
* (MAX_PHOTON_NUMBER + 1), # (*) pi amplitudes
"selective_transmon_X90_amplitudes": [0.4] * (MAX_PHOTON_NUMBER + 1),
# ── SWAP drive ────────────────────────────────────────────────────
# This setup drives the SWAP line from an SG channel, which is put into
# LF mode with its local oscillator at 0 Hz. Leave this out for a SWAP
# line on a baseband channel, such as an HDAWG output.
"swap_drive_on_sg_channel": True,
# ── Readout ───────────────────────────────────────────────────────
"readout_lo_frequency": 6.8e9,
"readout_resonator_frequency": 7.0e9,
"readout_amplitude": 0.5,
"readout_length": 1e-6,
"readout_pulse": {"function": "const"},
"readout_integration_length": 1e-6,
"readout_integration_delay": 20e-9,
# ── Reset ─────────────────────────────────────────────────────────
"reset_delay_length": 1e-6,
# ── Memory-transmon coupling (*) ──────────────────────────────────
"chi": -1e6, # (*) Dispersive shift in Hz
# ── Displacement calibration result (*) ───────────────────────────
"displacement_amp_per_unit_beta": None, # (*) To be calibrated — output of this experiment
}
q0 = BosonicQubit.from_logical_signal_group(
uid="q0",
lsg=device_setup.logical_signal_groups["q0"],
parameters=copy.deepcopy(QUBIT_PARAMS),
)
qubits = [q0]
if number_of_qubits == 2:
q1 = BosonicQubit.from_logical_signal_group(
uid="q1",
lsg=device_setup.logical_signal_groups["q1"],
parameters=copy.deepcopy(QUBIT_PARAMS),
)
qubits.append(q1)
for q in qubits:
print("-------------")
print("Qubit UID:", q.uid)
print("Qubit logical signals:")
for sig, lsg in q.signals.items():
print(f" {sig:<30} ('{lsg}')")
Quantum Operations¶
Create the set of BosonicQubitOperations:
qops = BosonicQubitOperations()
QPU¶
Create the QPU object from the qubits and the quantum operations:
from laboneq.dsl.quantum import QPU
qpu = QPU(qubits, quantum_operations=qops)
Connect to Session¶
session = Session(device_setup)
session.connect(do_emulation=True) # set do_emulation=False when at a real setup
Create a FolderStore for Saving Data¶
The experiment Workflows can automatically save the inputs and outputs of all their tasks to the folder path we specify when instantiating the FolderStore. Here, we choose the current working directory.
from pathlib import Path
folder_store = workflow.logbook.FolderStore(Path.cwd() / "data")
We disable saving in this guide. To enable it, simply run folder_store.activate().
folder_store.deactivate()
Optional: Configure the LoggingStore¶
You can also activate/deactivate the LoggingStore, which controls the display of Workflow logging information in the notebook. See the tutorial on Recording Experiment Workflow Results for details.
Displaying the Workflow logging information is activated by default, but here we deactivate it to shorten the outputs, which are not very meaningful in emulation mode.
We recommend that you do not deactivate the Workflow logging in practice.
from laboneq.workflow.logbook import LoggingStore
logging_store = LoggingStore()
logging_store.deactivate()
Running the Experiment Workflow¶
You'll now instantiate the experiment workflow and run it. For more details on what experiment workflows are and what tasks they execute, see the Experiment Workflows tutorial.
Start by importing the displacement_calibration experiment workflow from the bosonic qubits contribution, as well as plot_simulation for inspecting the pulse sequence.
from laboneq.contrib.example_helpers.plotting.plot_helpers import plot_simulation
from laboneq_applications.contrib.experiments.bosonic_qubits import (
displacement_calibration,
)
Configure the experiment options¶
Let's create the options object for the displacement calibration experiment and inspect it using workflow.show_fields:
options = displacement_calibration.experiment_workflow.options()
workflow.show_fields(options)
Notice that, unless we change it:
- The experiment is run in
AcquisitionType.INTEGRATIONandAveragingMode.CYCLIC, using 1024 averages (count). - The analysis workflow runs automatically after the experiment (
do_analysis=True). It extracts the photon number distributions, fits them to Poisson distributions, and returns the calibrateddisplacement_amp_per_unit_betafor each qubit. - The figures produced by the analysis are automatically closed (
close_figures=True) - The qubit parameters will not be updated (
update=False).
The create_experiment sub-options expose additional pulse overrides for advanced use:
memory_drive_length: override for the displacement pulse duration (defaults toqubit.parameters.memory_drive_length).memory_drive_pulse: pulse shape override for the displacement pulse.transmon_pi_pulse: pulse shape override for the transmon \(\pi\) pulse.
Here, let's reduce the number of averages for a quicker run in emulation mode, and disable closing the figures produced by the analysis so we see them in the cell output. Note that the fits attempted by the analysis in emulation mode are not representative, because we do not acquire data from a real experiment.
options.count(1)
options.close_figures(False)
Define the displacement amplitude and frequency sweeps¶
The displacement calibration requires two sweep axes:
- Amplitude sweep — the hardware drive amplitude of the displacement pulse on the memory cavity. Start from 0 and increase to a value where the mean photon number reaches a few photons (typically \(\bar{n} \approx 3\)–\(5\)).
- Frequency sweep — the RF frequency of the transmon \(\pi\) pulse. The sweep must cover the photon-number-resolved spectroscopy peaks at \(f_\mathrm{transmon}(n) = f_\mathrm{transmon}(0) + n \cdot \chi\) for \(n = 0, 1, \ldots, N_\mathrm{max}\), with sufficient margin on either side.
Both arrays must have the same number of points for all qubits when running in parallel (since the hardware sweeps them in lock-step). However, each qubit can have an independently chosen sweep range.
qubit = qpu.quantum_elements[0]
f0 = qubit.parameters.transmon_resonance_frequency_at_n0
chi = qubit.parameters.chi
# Few sweep points keep the emulated run short; use denser sweeps on hardware
# (see the parallel example below).
# Displacement amplitude sweep
amplitudes_q0 = np.linspace(0, 0.8, 2)
# Transmon drive frequency sweep (RF frequencies in Hz).
# Cover the photon-number peaks at f(n) = f0 + n * chi for n = 0 ... MAX_PHOTON_NUMBER + 1,
# with a margin of |chi| on each side. This assumes chi < 0, so the peaks lie below f0.
# The analysis needs more frequency points than peaks in the sweep; a step of |chi| / 2
# puts a point on every peak.
frequencies_q0 = np.linspace(
f0 + (MAX_PHOTON_NUMBER + 1) * chi - abs(chi),
f0 + abs(chi),
2 * (MAX_PHOTON_NUMBER + 3) + 1,
)
print(
f"Amplitude sweep: {amplitudes_q0[0]:.3f} to {amplitudes_q0[-1]:.3f} ({len(amplitudes_q0)} points)"
)
print(
f"Frequency sweep: {frequencies_q0[0] / 1e9:.4f} to {frequencies_q0[-1] / 1e9:.4f} GHz ({len(frequencies_q0)} points)"
)
print(
f"Expected peaks at (GHz): {[f'{(f0 + n * chi) / 1e9:.4f}' for n in range(MAX_PHOTON_NUMBER + 1)]}"
)
# Run on a single qubit
exp_workflow = displacement_calibration.experiment_workflow(
session=session,
qpu=qpu,
qubits=[qubit.uid],
amplitudes=[amplitudes_q0],
frequencies=[frequencies_q0],
options=options,
)
workflow_results = exp_workflow.run()
Inspect the Tasks That Were Run¶
The displacement calibration workflow executes four steps in sequence: create_experiment, compile_experiment, run_experiment, and (when do_analysis=True) the analysis_workflow, which runs the fit_data, extract_qubit_parameters and plotting tasks. With update=True, an update_qpu task then stores the new parameter values in the qpu.
for t in workflow_results.tasks:
print(t)
Inspect the Output Simulation¶
You can inspect the compiled experiment and plot the simulated output to verify that the pulse sequence looks as expected. The displacement calibration sequence consists of a short displacement pulse on the memory cavity drive line, followed by a transmon \(\pi\) pulse and a readout pulse.
The total duration of one repetition is approximately:
Note that the full experiment sweeps over len(amplitudes) × len(frequencies) repetitions. We plot just the first repetition here to inspect the pulse shapes.
q = qpu.quantum_elements[0]
total_time = (
q.parameters.memory_drive_length
+ q.parameters.selective_transmon_Rx_length
+ q.parameters.readout_length
+ q.parameters.reset_delay_length
)
compiled_experiment = workflow_results.tasks["compile_experiment"].output
plot_simulation(
compiled_experiment,
length=total_time * 1.05,
)
Inspect the Source Code of the Pulse-Sequence Creation Task¶
You can inspect the source code of the create_experiment task to see exactly how the pulse sequence is constructed using quantum operations. To learn more about quantum operations, see the Quantum Operations tutorial.
displacement_calibration.create_experiment.src
Analyse the Results¶
Because do_analysis=True by default, the analysis workflow ran automatically after the experiment. It performed the following steps for each qubit:
- Reshaped the raw 1D result data into a 2D array of shape
(n_amplitudes, n_frequencies). - For each amplitude slice, extracted the heights of the photon-number peaks at \(f_\mathrm{transmon}(n) = f_\mathrm{transmon}(0) + n \cdot \chi\) for \(n = 0, 1, \ldots\)
- Fitted the peak heights to a Poisson distribution to extract the mean photon number \(\bar{n}\).
- Fitted \(A\) vs. \(\sqrt{\bar{n}}\) with a line through the origin to extract
displacement_amp_per_unit_beta.
Three plots are generated automatically:
- 2D displacement map — the transmon spectrum magnitude as a function of displacement amplitude and transmon drive frequency, with dashed lines indicating the expected photon-number peak positions.
- Photon number distributions — the extracted photon number distributions \(P(n)\) with Poisson fits for a selection of amplitude values.
- \(\bar{n}\) vs. amplitude — the fitted and directly computed mean photon numbers as a function of displacement amplitude, showing the \(\bar{n} \propto A^2\) relationship.
Access the Analysis Outputs¶
# Access the fit results of the analysis workflow.
analysis_result = workflow_results.tasks["analysis_workflow"].tasks["fit_data"].output
q_uid = qpu.quantum_elements[0].uid
q_result = analysis_result[q_uid]
# Keys available in the per-qubit result dict:
print("Available result keys:", list(q_result.keys()))
Extract the Calibration Constant¶
The analysis fits the relationship \(A = \texttt{displacement\_amp\_per\_unit\_beta} \times |\beta|\) where \(|\beta| = \sqrt{\bar{n}}\). The fitted slope is stored in the result dict:
disp_cal = q_result["displacement_amp_per_unit_beta"]
print(f"displacement_amp_per_unit_beta for {q_uid}: {disp_cal}")
if np.isfinite(disp_cal):
print("\nExample conversions:")
print(f" |beta| = 1.0 (n_bar = 1.0) -> amplitude = {disp_cal * 1.0:.4f}")
print(f" |beta| = 2.0 (n_bar = 4.0) -> amplitude = {disp_cal * 2.0:.4f}")
print(f" |beta| = sqrt(5) (n_bar = 5) -> amplitude = {disp_cal * 5**0.5:.4f}")
Update the Qubit Parameters¶
The analysis workflow returns the extracted qubit parameters together with their previous values. Set options.update(True) to store the new values in the qpu automatically when the workflow runs. Here, we update the qpu manually instead. Qubits without a valid fit have no new values and keep their previous parameters.
Once displacement_amp_per_unit_beta is set, the displacement quantum operation can accept beta or n_bar directly as arguments, and will compute the required hardware amplitude automatically.
from pprint import pprint
qubit_parameters = workflow_results.tasks["analysis_workflow"].output
pprint(qubit_parameters)
qpu.update(qubit_parameters["new_parameter_values"])
Parallel Displacement Calibration¶
The displacement_calibration workflow natively supports running multiple qubits in parallel. When you pass a list of qubit UIDs together with corresponding lists of amplitude and frequency arrays, the pulse sequences for all qubits are compiled into a single experiment and executed simultaneously on the hardware — halving experiment time compared to sequential single-qubit runs.
Each qubit gets its own independently chosen amplitude and frequency sweep, so qubits with different transmon frequencies or dispersive shifts can still be characterised in one shot. The only constraint is that all amplitude arrays must have the same number of points, and all frequency arrays must have the same number of points, because the hardware sweeps them in lock-step.
Run the two-qubit experiment¶
Pass both qubit UIDs and their sweep arrays to the workflow:
# Store the calibrated values in the qpu automatically.
options.update(True)
q0 = qpu.quantum_elements[0]
q1 = qpu.quantum_elements[1]
# Each qubit gets its own sweep, independently centred on its transmon frequency.
# As above, the frequency ranges assume chi < 0.
f0_q0 = q0.parameters.transmon_resonance_frequency_at_n0
chi_q0 = q0.parameters.chi
f0_q1 = q1.parameters.transmon_resonance_frequency_at_n0
chi_q1 = q1.parameters.chi
amplitudes_q0 = np.linspace(0, 0.8, 21)
amplitudes_q1 = np.linspace(0, 0.8, 21)
frequencies_q0 = np.linspace(
f0_q0 + (MAX_PHOTON_NUMBER + 1) * chi_q0 - abs(chi_q0),
f0_q0 + abs(chi_q0),
501,
)
frequencies_q1 = np.linspace(
f0_q1 + (MAX_PHOTON_NUMBER + 1) * chi_q1 - abs(chi_q1),
f0_q1 + abs(chi_q1),
501,
)
exp_workflow_2q = displacement_calibration.experiment_workflow(
session=session,
qpu=qpu,
qubits=[q0.uid, q1.uid],
amplitudes=[amplitudes_q0, amplitudes_q1],
frequencies=[frequencies_q0, frequencies_q1],
options=options,
)
workflow_results_2q = exp_workflow_2q.run()
Check the Updated Qubit Parameters¶
With update=True, the workflow stored the new parameter values in the qpu. Qubits without a valid fit keep their previous parameters:
qubit_parameters_2q = workflow_results_2q.tasks["analysis_workflow"].output
pprint(qubit_parameters_2q["new_parameter_values"])
for q in [q0, q1]:
print(
f"{q.uid}: displacement_amp_per_unit_beta = {q.parameters.displacement_amp_per_unit_beta}"
)
Great! You have now calibrated the displacement amplitude for all qubits. The displacement_amp_per_unit_beta parameter is stored in the qubit objects and will be used by all subsequent experiments that perform displacement operations on the memory cavity — including state preparation for SWAP calibration, SNAP gate calibration, and Wigner tomography.
The next step in the bosonic qubit tune-up sequence is the Selective Transmon Rx calibration, which calibrates the number-selective transmon \(\pi\) and \(\pi/2\) pulse amplitudes.