Skip to content

SNAP Gate 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 SNAP gate calibration experiment, the following calibrations must have been performed:

  • Transmon spectroscopy: the steady-state ancilla transmon resonance frequency transmon_resonance_frequency_at_n0 must be known.
  • Dispersive shift: the dispersive shift chi between the memory cavity and the ancilla transmon must be measured, so that each number-selective drive line is tuned to the correct frequency.
  • Displacement calibration: the conversion factor displacement_amp_per_unit_beta must be known (see the Displacement Calibration how-to guide), to prepare coherent states for photon-number-selective state preparation.
  • Selective transmon Rx calibration: the number-selective transmon π-pulse amplitudes selective_transmon_X180_amplitudes[n] must be calibrated for the target photon number (see the Selective Transmon Rx Calibration how-to guide).

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 SNAP (Selective Number-Arbitrary Phase) gate on a bosonic memory cavity using the snap_calibration experiment workflow in the LabOne Q Applications Library.

The SNAP gate

The SNAP gate applies an independent phase to each Fock state \(|n\rangle\) of the memory cavity:

\[S(\boldsymbol{\theta}) = \sum_n e^{i\theta_n} |n\rangle\langle n|\]

It is implemented with two sequential number-selective transmon pulses. Because the dispersive interaction shifts the transmon frequency by \(n \cdot \chi\) when the cavity contains \(n\) photons, a drive at frequency \(f_\mathrm{transmon}(n) = f_\mathrm{transmon}(0) + n\chi\) flips the transmon selectively for the \(|n\rangle\) Fock state. Applying two \(\pi\) pulses with a relative drive phase \(\phi\) between them imprints the phase \(\theta_n\) on the cavity without leaving a net transmon excitation: the first \(\pi\) pulse maps \(|g,n\rangle \to |e,n\rangle\), and the second \(\pi\) pulse (at drive phase \(\phi\)) maps \(|e,n\rangle \to e^{i\theta_n(\phi)}|g,n\rangle\).

The calibration workflow runs in two sequential parts:

Part 1 — Amplitude calibration

The workflow calibrates the two-pulse SNAP π amplitude for photon number \(n\). The pulse sequence prepares a coherent state \(|\beta\rangle\) with \(|\beta| = \sqrt{n}\) in the cavity and sweeps the selective drive amplitude while observing the transmon population:

\[\text{qubit} \;---\; [D(|\beta| = \sqrt{n})] \;---\; [R_x(\mathrm{amp},\, n)] \;---\; [R_x(\mathrm{amp},\, n)] \;---\; [\mathrm{measure}]\]

Both pulses rotate the transmon in the same direction. Together they complete a \(2\pi\) rotation at the π amplitude, so the transmon returns to \(|g\rangle\) — giving a minimum in the transmon signal. The analysis fits

\[P(e) = \mathrm{contrast} \cdot \sin^2\!\left(\frac{\pi \cdot \mathrm{amp}}{A_\pi}\right) + \mathrm{offset}\]

and extracts the SNAP π amplitude \(A_\pi\).

Part 2 — Phase calibration

Part 2 maps the SNAP drive phase \(\phi\) to the acquired cavity phase \(\theta_n\). The pulse sequence prepares the cavity in \(|\beta\rangle\) with \(|\beta| = \sqrt{n}\), applies the two-pulse SNAP sequence (using the calibrated \(A_\pi\) from Part 1) with a variable drive phase \(\phi\) on the second pulse, and reads out the imprinted cavity phase via Wigner tomography:

\[\text{qubit} \;---\; [D(|\beta| = \sqrt{n})] \;---\; [R_x(A_\pi,\, n,\, \phi{=}0)] \;---\; [R_x(A_\pi,\, n,\, \phi)] \;---\; [W(|\beta|{=}\sqrt{n},\, \arg\beta)]\]

The Wigner displacement phase \(\arg\beta\) is swept around the full circle at fixed radius \(|\beta| = \sqrt{n}\), tracing a Wigner fringe whose angular position \(\theta(\phi)\) is the imprinted cavity phase. The analysis fits \(\theta(\phi)\) vs \(\phi\) to a line (expected slope \(\approx \pm 1\)) and records the intercept as the phase calibration constant.

Calibration outputs

The workflow produces three calibration values per qubit per photon number \(n\):

Parameter Stored in Description
\(A_\pi\) qubit.parameters.snap_drive_amp_per_n[str(n)] Two-pulse SNAP π amplitude
\(\theta_0\) qubit.parameters.snap_drive_phase_per_n[str(n)] Drive-phase to cavity-phase offset (rad)
\(\mathrm{sign}(\mathrm{slope})\) qubit.parameters.snap_drive_phase_slope_sign_per_n[str(n)] Sign (\(\pm 1\)) of the fitted \(\theta(\phi)\) slope

Once populated, these are used automatically by the snap quantum operation.

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:

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 SNAP calibration experiment requires signal lines for the memory cavity drive, the number-selective transmon drives, 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) — used both to prepare coherent states via displacement and to perform Wigner tomography displacement in Part 2
  • 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) — not used in this experiment, but included so the same DeviceSetup can be reused across all bosonic qubit experiments

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) — displacement and Wigner tomography
    create_connection(to_signal="q0/drive_memory", ports="SGCHANNELS/0/OUTPUT"),
    # SWAP drive — not used here, included for setup reuse
    create_connection(to_signal="q0/swap_drive", ports="SGCHANNELS/4/OUTPUT"),
)
# 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 SNAP calibration are highlighted in the comments.

The snap_drive_amp_per_n and snap_drive_phase_per_n dictionaries are initially empty — they will be populated by this calibration.

# Qubit parameters — adjust these to match your device.
# Parameters marked (*) are directly relevant to the SNAP calibration experiment.
QUBIT_PARAMS = {
    # ── Memory cavity ────────────────────────────────────────────────
    "memory_lo_frequency": 5.8e9,
    "memory_resonance_frequency": 6.0e9,
    "memory_drive_amplitude": 0.5,
    "memory_drive_length": 0.1e-6,  # (*) Displacement pulse duration
    "memory_drive_pulse": {"function": "gaussian", "sigma": 0.25},
    "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,  # (*) From transmon spectroscopy
    "transmon_drive_range": 10,
    # ── Selective transmon Rx drives (*) ─────────────────────────────
    # These must be calibrated before running the SNAP calibration.
    "max_photon_number": MAX_PHOTON_NUMBER,
    "selective_transmon_Rx_length": 0.08e-6,  # (*) Selective pulse duration
    "selective_transmon_Rx_pulse": {"function": "gaussian", "sigma": 0.25},
    "selective_transmon_X180_amplitudes": [0.8]
    * (MAX_PHOTON_NUMBER + 1),  # (*) From selective Rx calibration
    "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,
    # ── SNAP gate parameters (*) ──────────────────────────────────────
    # Initially empty — populated by this calibration workflow.
    "snap_drive_amp_per_n": {},  # (*) To be calibrated (Part 1)
    "snap_drive_phase_per_n": {},  # (*) To be calibrated (Part 2)
    "snap_drive_phase_slope_sign_per_n": {},  # (*) To be calibrated (Part 2)
    # ── SWAP gate ─────────────────────────────────────────────────────
    "swap_length": 500e-9,
    "swap_amplitude": 0.1,
    "swap_pulse": {"function": "const"},
    "swap_drive_range": 5,
    # ── 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 (*) ─────────────────────────────────
    "displacement_amp_per_unit_beta": 0.2,  # (*) Must be calibrated before 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 snap_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 snap_calibration

Configure the experiment options

Let's create the options object for the SNAP calibration workflow and inspect it using workflow.show_fields:

options = snap_calibration.experiment_workflow.options()
workflow.show_fields(options)

Notice that, unless we change it:

  • The experiment is run in AcquisitionType.INTEGRATION and AveragingMode.CYCLIC, using 1024 averages (count).
  • The analysis workflows amplitude_analysis_workflow and phase_analysis_workflow run automatically after each part (do_analysis=True).
  • The figures produced by the analysis are automatically closed (close_figures=True)
  • The qubit parameters will not be updated (update=False).
  • Both Part 1 (amplitude calibration) and Part 2 (phase calibration) are run by default (do_phase_calibration=True). Set do_phase_calibration(False) to run Part 1 only. Part 2 uses the π amplitudes extracted by the Part 1 analysis, so it runs only when do_analysis=True.

The experiment sub-options expose additional pulse overrides for advanced use:

  • selective_drive_pulse / selective_drive_length: shape and duration overrides for the selective transmon drive pulses.
  • memory_drive_pulse / memory_drive_length: shape and duration overrides for the displacement pulse.
  • wigner_displacement_pulse / wigner_displacement_length: shape and duration overrides for the Wigner tomography displacement pulse (Part 2 only).
  • chunk_count: number of sweep-point chunks for real-time execution (default: 1).

The Part 2 fit options min_fringe_r2 / max_slope_dev set the quality thresholds for the fringe and line fits.

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 photon number and sweep parameters

The SNAP calibration is run one photon number at a time. Specify the target photon number using the photon_number_memory argument.

Pass each sweep as a list with one 1-D array per qubit (or a single 1-D array when running on a single qubit). The two phase sweeps also accept a single 1-D array, which is broadcast to all qubits. When multiple qubits are used, all arrays for the same parameter must have the same number of points.

Argument Part Description
amplitudes_transmon 1 Selective drive amplitude sweep, centred around the selective Rx π amplitude at the target photon number
phases_transmon 2 SNAP drive-phase outer sweep; None defaults to 13 evenly spaced points over \([0, 2\pi)\)
phases_wigner 2 Wigner displacement-phase inner sweep; None defaults to 48 evenly spaced points over \([0, 2\pi)\)

The recommended practice is to pass all three explicitly so the sweep grids are visible in the notebook:

# Target photon number for this calibration run.
# Repeat with other photon numbers to build up a full SNAP calibration.
PHOTON_NUMBER = 1

qubit = qpu.quantum_elements[0]

# Amplitude sweep (Part 1) — wide enough to contain the selective Rx pi amplitude at the target photon number.
amplitudes_q0 = np.linspace(0.1, 0.9, 51)

# SNAP drive-phase sweep (Part 2, outer) — 11 points is coarse enough since theta(phi) is linear.
phases_transmon_q0 = np.linspace(0, 2 * np.pi, 11, endpoint=False)

# Wigner displacement-phase sweep (Part 2, inner) — 51 points for a well-sampled fringe.
phases_wigner_q0 = np.linspace(0, 2 * np.pi, 51, endpoint=False)

print(f"Photon number          : {PHOTON_NUMBER}")
print(
    f"Amplitude sweep        : {amplitudes_q0[0]:.3f} to {amplitudes_q0[-1]:.3f} "
    f"({len(amplitudes_q0)} points)"
)
print(
    f"SNAP drive-phase sweep : {phases_transmon_q0[0]:.3f} to {phases_transmon_q0[-1]:.3f} "
    f"({len(phases_transmon_q0)} points)"
)
print(
    f"Wigner phase sweep     : {phases_wigner_q0[0]:.3f} to {phases_wigner_q0[-1]:.3f} "
    f"({len(phases_wigner_q0)} points)"
)
exp_workflow = snap_calibration.experiment_workflow(
    session=session,
    qpu=qpu,
    qubits=[qubit.uid],
    amplitudes_transmon=[amplitudes_q0],
    phases_transmon=[phases_transmon_q0],
    phases_wigner=[phases_wigner_q0],
    photon_number_memory=PHOTON_NUMBER,
    options=options,
)

workflow_results = exp_workflow.run()

Inspect the Tasks That Were Run

The SNAP calibration workflow executes two sets of tasks in sequence:

  • Part 1: create_amplitude_experiment → compile_amplitude_experiment → run_amplitude_experiment → amplitude_analysis_workflow (fit_amplitude_data → extract_amplitude_parameters → plot_amplitude_calibration)
  • Part 2 (when do_phase_calibration=True): create_phase_experiment → compile_phase_experiment → run_phase_experiment → phase_analysis_workflow (fit_phase_data → extract_phase_parameters → plot_phase_calibration)

With update=True, an update_qpu task follows each analysis workflow.

for t in workflow_results.tasks:
    print(t)

Inspect the Output Simulation (Part 1)

You can inspect the compiled Part 1 experiment and plot the simulated output to verify that the pulse sequence looks as expected. The SNAP amplitude calibration sequence consists of a displacement pulse on the drive_memory line (for \(n > 0\)), two sequential selective π pulses on the drive_transmon_at_n{n} line, and a readout pulse.

The total duration of one real-time repetition is approximately:

\[T \approx t_\mathrm{displacement} + 2 \times t_{Rx} + t_\mathrm{readout} + t_\mathrm{reset}\]
q = qpu.quantum_elements[0]
total_time = (
    q.parameters.memory_drive_length
    + 2 * q.parameters.selective_transmon_Rx_length
    + q.parameters.readout_length
    + q.parameters.reset_delay_length
)

compiled_experiment = workflow_results.tasks["compile_amplitude_experiment"].output
plot_simulation(
    compiled_experiment,
    length=total_time * 1.05,
)

Inspect the Source Code of the Amplitude Pulse-Sequence Creation Task

You can inspect the source code of the create_amplitude_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.

snap_calibration.create_amplitude_experiment.src

Inspect the Source Code of the Phase Pulse-Sequence Creation Task

Similarly, you can inspect the source code for Part 2:

snap_calibration.create_phase_experiment.src

Part 1 — Amplitude Calibration

Analyse the Results

Because do_analysis=True by default, the amplitude analysis workflow ran automatically after Part 1. For the target qubit it performed the following steps:

  1. Retrieved the transmon signal from the result handle {qubit_uid}/snap_amp_n{n}.
  2. Fitted the two-pulse SNAP Rabi model
    \[P(e) = \mathrm{contrast} \cdot \sin^2\!\left(\frac{\pi \cdot \mathrm{amp}}{A_\pi}\right) + \mathrm{offset}\]
    to extract the SNAP π amplitude \(A_\pi\).

One plot is generated automatically:

  • SNAP amplitude calibration — the transmon signal versus selective drive amplitude, with the two-pulse Rabi fit and a vertical dashed line marking \(A_\pi\).

Access the Analysis Outputs

# Access the amplitude calibration results.
analysis_amp = (
    workflow_results.tasks["amplitude_analysis_workflow"]
    .tasks["fit_amplitude_data"]
    .output
)

q_uid = qpu.quantum_elements[0].uid
q_result_amp = analysis_amp[q_uid]

# Keys available in the per-qubit result dict:
print("Available result keys:", list(q_result_amp.keys()))

The per-qubit result dictionary contains:

Key Description
"amp_pi_snap" Dict mapping str(n) to the calibrated SNAP π amplitude
"rabi_data" Dict with fit results: raw_data, fit_params, fit_success, amp_pi, fit_x, fit_y
"amplitudes" 1D array of swept selective drive amplitudes
"photon_number" Target photon number \(n\)

Extract the Calibrated SNAP π Amplitude

amp_pi_snap = q_result_amp["amp_pi_snap"][str(PHOTON_NUMBER)]
fit_ok_amp = q_result_amp["rabi_data"]["fit_success"]

print(f"Qubit {q_uid}:")
print(f"  SNAP pi amplitude (n={PHOTON_NUMBER}) : {amp_pi_snap:.4f}")
print(f"  Fit success             : {fit_ok_amp}")

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.

For Part 1, the new value is snap_drive_amp_per_n, which maps the photon number (as a string key) to the SNAP π amplitude.

from pprint import pprint

amp_parameters = workflow_results.tasks["amplitude_analysis_workflow"].output
pprint(amp_parameters)

qpu.update(amp_parameters["new_parameter_values"])

Part 2 — Phase Calibration

Analyse the Results

Note

The Part 2 phase calibration sequence uses a Wigner function measurement to probe the cavity state: after applying a SNAP pulse, the cavity is displaced at amplitude \(|\beta| = \sqrt{n}\) with varying phase \(\arg(\beta)\), and the transmon measures the photon-number parity. For a detailed description of the Wigner tomography pulse sequence and how to run it as a standalone experiment, see the Wigner Tomography how-to guide.

After Part 1, the workflow automatically ran Part 2 using the calibrated SNAP π amplitude. The analysis performed a two-stage fit:

Stage 1 — For each SNAP drive phase \(\phi\), the Wigner fringe

\[W(\arg\beta) = A \cdot \cos(\arg\beta - \theta) + B\]
was fitted to extract the fringe angular position \(\theta(\phi)\). The fit quality \(R^2\) is recorded; fringes with \(R^2 <\) min_fringe_r2 (default 0.5) are excluded from Stage 2.

Stage 2 — The extracted fringe angles \(\theta(\phi)\) were fitted to a line:

\[\theta(\phi) = \mathrm{slope} \cdot \phi + \mathrm{intercept}\]
The intercept is the phase calibration constant. The slope is a calibration quality check: a value close to \(\pm 1\) indicates a good calibration; a slope far from \(\pm 1\) suggests a readout or state-preparation problem.

Two plots are generated automatically:

  • Wigner fringe map — a 2D colour map of the Wigner signal as a function of SNAP drive phase (y axis) and Wigner displacement phase (x axis), with the extracted fringe angles overlaid as white dots.
  • Phase calibration line — the unwrapped fringe angle \(\theta(\phi)\) versus drive phase \(\phi\), with the line fit, the slope and intercept annotated.

Access the Analysis Outputs

# Access the phase calibration results.
analysis_phase = (
    workflow_results.tasks["phase_analysis_workflow"].tasks["fit_phase_data"].output
)

q_result_phase = analysis_phase[q_uid]

# Keys available in the per-qubit result dict:
print("Available result keys:", list(q_result_phase.keys()))

The per-qubit result dictionary contains:

Key Description
"phase_offsets" Dict mapping str(n) to the calibration constant (intercept, rad)
"phase_slope_signs" Dict mapping str(n) to the sign (\(\pm 1\)) of the fitted slope
"calibration" Dict with slope, intercept, slope_ok (bool), n_good_fringes (int)
"fringe_angles" 1D array of extracted Wigner fringe angles \(\theta(\phi)\), one per drive-phase point
"fringe_r2" 1D array of \(R^2\) values for the Stage-1 fringe fits
"phases" 1D array of swept SNAP drive phases \(\phi\) (rad)
"phases_wigner" 1D array of swept Wigner displacement phases \(\arg\beta\) (rad)
"line_fit" Dict with the Stage-2 line-fit details: slope, intercept, and, if at least two fringes passed, intercept_raw and the sorted, unwrapped points phi_sorted / theta_unwrapped
"data_2d" 2D array of Wigner signal, shape (len(phases_transmon), len(phases_wigner))
"photon_number" Target photon number \(n\)

Extract the Phase Calibration Constant

phase_offset = q_result_phase["phase_offsets"][str(PHOTON_NUMBER)]
calibration = q_result_phase["calibration"]
slope = calibration["slope"]
slope_ok = calibration["slope_ok"]
n_good = calibration["n_good_fringes"]
n_total = len(q_result_phase["phases"])

print(f"Qubit {q_uid}:")
print(
    f"  Phase offset (n={PHOTON_NUMBER})  : {phase_offset:.4f} rad ({np.degrees(phase_offset):.2f} deg)"
)
print(f"  Slope               : {slope:.4f}")
print(f"  Slope ok            : {slope_ok}")
print(f"  Good fringes used   : {n_good} / {n_total}")

A slope_ok = True result indicates a reliable calibration — the fitted \(\theta(\phi)\) line has a slope within the expected range of \(|\mathrm{slope}| \in [1 - \Delta, 1 + \Delta]\) where \(\Delta\) is set by the max_slope_dev option (default: 0.3).

If slope_ok = False or n_good_fringes is much smaller than the total number of drive-phase points, consider:

  • Increasing the number of averages for better signal-to-noise on individual Wigner fringes.
  • Widening the Wigner displacement-phase sweep by increasing the number of points in phases_wigner.
  • Re-running Part 1 with a finer amplitude sweep to verify the SNAP π amplitude.

Update the Qubit Parameters

For Part 2, the new values are snap_drive_phase_per_n and snap_drive_phase_slope_sign_per_n; they are only extracted when slope_ok = True. Together with snap_drive_amp_per_n, these fully characterise the SNAP gate at photon number \(n\) and are used automatically by the snap quantum operation.

from pprint import pprint

phase_parameters = workflow_results.tasks["phase_analysis_workflow"].output
pprint(phase_parameters)

qpu.update(phase_parameters["new_parameter_values"])

qubit = qpu.quantum_elements[0]
print(f"\nSNAP calibration for n={PHOTON_NUMBER}:")
print(f"  snap_drive_amp_per_n = {qubit.parameters.snap_drive_amp_per_n}")
print(f"  snap_drive_phase_per_n = {qubit.parameters.snap_drive_phase_per_n}")
print(
    f"  snap_drive_phase_slope_sign_per_n = "
    f"{qubit.parameters.snap_drive_phase_slope_sign_per_n}"
)

Calibrating Additional Photon Numbers

To calibrate the SNAP gate for additional Fock states, repeat the workflow for each target photon number. Here we set update=True, so each run stores its calibrated values in the qpu. The loop below iterates over [1, 2] as an example — adjust the list and amplitude sweep to match your device.

qubit = qpu.quantum_elements[0]
options.update(True)

# Phase sweeps are the same for every photon number in this example.
phases_transmon = np.linspace(0, 2 * np.pi, 13, endpoint=False)
phases_wigner = np.linspace(0, 2 * np.pi, 48, endpoint=False)

for n in [1, 2]:
    amps = np.linspace(0.1, 0.9, 51)

    wf = snap_calibration.experiment_workflow(
        session=session,
        qpu=qpu,
        qubits=[qubit.uid],
        amplitudes_transmon=[amps],
        phases_transmon=[phases_transmon],
        phases_wigner=[phases_wigner],
        photon_number_memory=n,
        options=options,
    )
    res = wf.run()

    for part in ("amplitude", "phase"):
        new = res.tasks[f"{part}_analysis_workflow"].output["new_parameter_values"]
        status = "updated" if new[qubit.uid] else "FAILED, not updated"
        print(f"n={n}: {part} calibration {status}")

print("\nFinal snap_drive_amp_per_n  :", qubit.parameters.snap_drive_amp_per_n)
print("Final snap_drive_phase_per_n:", qubit.parameters.snap_drive_phase_per_n)
print(
    "Final snap_drive_phase_slope_sign_per_n:",
    qubit.parameters.snap_drive_phase_slope_sign_per_n,
)

Parallel SNAP Calibration

The snap_calibration workflow natively supports running multiple qubits in parallel. When you pass a list of qubit UIDs together with corresponding lists of amplitude arrays, the pulse sequences for all qubits are compiled into a single experiment and executed simultaneously on the hardware.

Each qubit gets its own independently chosen amplitude sweep, so qubits with different SNAP π amplitudes can be characterised in one shot. The only constraint is that all amplitude arrays must have the same number of points, because the hardware sweeps them in lock-step. Both qubits must also share the same photon_number_memory.

Here we demonstrate a parallel calibration at photon number 1 on both qubits:

q0 = qpu.quantum_elements[0]
q1 = qpu.quantum_elements[1]

PHOTON_NUMBER = 1

# Each qubit gets its own amplitude sweep; the ranges may differ between qubits.
amplitudes_q0 = np.linspace(0.1, 0.9, 51)
amplitudes_q1 = np.linspace(0.1, 0.9, 51)

# Phase sweeps are shared across qubits here, but can be set per-qubit if needed.
phases_transmon_q0 = np.linspace(0, 2 * np.pi, 13, endpoint=False)
phases_transmon_q1 = np.linspace(0, 2 * np.pi, 13, endpoint=False)

phases_wigner_q0 = np.linspace(0, 2 * np.pi, 48, endpoint=False)
phases_wigner_q1 = np.linspace(0, 2 * np.pi, 48, endpoint=False)

exp_workflow_2q = snap_calibration.experiment_workflow(
    session=session,
    qpu=qpu,
    qubits=[q0.uid, q1.uid],
    amplitudes_transmon=[amplitudes_q0, amplitudes_q1],
    phases_transmon=[phases_transmon_q0, phases_transmon_q1],
    phases_wigner=[phases_wigner_q0, phases_wigner_q1],
    photon_number_memory=PHOTON_NUMBER,
    options=options,
)

workflow_results_2q = exp_workflow_2q.run()

Check the Updated Qubit Parameters

Because update=True is still set in options, the workflow stored the new parameter values in the qpu. Qubits without a valid fit keep their previous parameters:

for part in ("amplitude", "phase"):
    new = workflow_results_2q.tasks[f"{part}_analysis_workflow"].output
    pprint(new["new_parameter_values"])

for q in [q0, q1]:
    print(f"{q.uid}: snap_drive_amp_per_n = {q.parameters.snap_drive_amp_per_n}")
    print(f"{q.uid}: snap_drive_phase_per_n = {q.parameters.snap_drive_phase_per_n}")

Great! You have now calibrated the SNAP gate for the target photon number on all qubits. The snap_drive_amp_per_n, snap_drive_phase_per_n and snap_drive_phase_slope_sign_per_n parameters are stored in the qubit objects and will be used by the snap quantum operation in subsequent experiments.

Repeat this calibration for each additional photon number \(n\) you intend to use in SNAP gate sequences. Once all required Fock states are calibrated, the snap quantum operation can apply arbitrary independent phases to each cavity Fock state in a single compiled sequence.