Skip to content

Wigner Tomography

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 Wigner tomography 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.
  • Displacement calibration: the conversion factor displacement_amp_per_unit_beta must be known (see the Displacement Calibration how-to guide).
  • Selective transmon Rx calibration: the parity mapping uses \(R_y(\pm\pi/2)\) pulses on the \(n=0\) transition, so the π/2-pulse amplitude selective_transmon_X90_amplitudes[0] must be calibrated (see the Selective Transmon Rx Calibration how-to guide).
  • Readout discrimination: the readout must be calibrated for single-shot state discrimination, since the experiment uses AcquisitionType.DISCRIMINATION and extracts \(P(|g\rangle)\) from the averaged result.

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 perform Wigner tomography on the memory cavity of a bosonic qubit using the wigner_tomography_demonstrator experiment workflow in the LabOne Q Applications Library.

The Wigner function

The Wigner function \(W(\beta)\) is a quasi-probability distribution in the phase space of a quantum harmonic oscillator. It provides a complete description of the oscillator's quantum state and is defined as:

\[W(\beta) = \frac{2}{\pi} \operatorname{Tr}\!\left[\hat{\rho}\,\hat{D}(\beta)\,\hat{\Pi}\,\hat{D}^\dagger(\beta)\right]\]

where \(\hat{\rho}\) is the cavity density matrix, \(\hat{D}(\beta)\) is the displacement operator, and \(\hat{\Pi} = (-1)^{\hat{n}}\) is the photon-number parity operator. For a pure state, the Wigner function can take negative values — a signature of non-classical behaviour. The vacuum state has a Gaussian Wigner function centred at the origin, while Fock states and cat states exhibit characteristic interference fringes with negative regions.

Parity measurement via the ancilla transmon

The photon-number parity is measured indirectly through the dispersively coupled ancilla transmon. The full protocol — implemented by the wigner_point quantum operation — is:

  1. Displacement \(\hat{D}(-\beta)\) — the memory cavity is displaced by \(-\beta\), mapping the phase-space point \(\beta\) to the origin.
  2. Parity mapping — a Ramsey interferometry sequence maps the cavity photon-number parity onto the transmon population.
  3. Readout — the transmon is read out; the result encodes \(\langle \hat{\Pi} \rangle\).

How the parity mapping works

The ancilla transmon and memory cavity are coupled dispersively. In the dispersive regime the interaction Hamiltonian is

\[H_\mathrm{disp} = \chi\, \hat{a}^\dagger \hat{a}\, |e\rangle\langle e|\]

where \(\chi\) is the dispersive shift, \(\hat{a}^\dagger \hat{a} = \hat{n}\) is the cavity photon-number operator, and \(|e\rangle\langle e|\) projects onto the transmon excited state. The key consequence is that each cavity Fock state \(|n\rangle\) shifts the transmon transition frequency by \(\chi n\), causing the transmon to accumulate a photon-number-dependent phase during free evolution.

The parity mapping sequence exploits this with a three-step Ramsey protocol:

\[R_y(+\pi/2) \;\longrightarrow\; \text{wait}\left(\frac{1}{2|\chi|}\right) \;\longrightarrow\; R_y(-\pi/2)\]

Starting with the transmon in \(|g\rangle\) and the cavity in Fock state \(|n\rangle\):

  1. \(R_y(+\pi/2)\) rotates the transmon into the superposition \(\tfrac{1}{\sqrt{2}}(|g\rangle + |e\rangle)\).
  2. During the wait time \(t = 1/(2|\chi|)\), the dispersive interaction causes the \(|e\rangle\) component to accumulate a phase \(\phi(n) = 2\pi \chi n\, t = \pi n\) relative to \(|g\rangle\). The joint state after the wait is
    \[\frac{1}{\sqrt{2}}\bigl(|g\rangle + e^{i\pi n}|e\rangle\bigr)|n\rangle.\]
  3. \(R_y(-\pi/2)\) converts the phase back into a population difference:
    • Even \(n\): \(e^{i\pi n} = +1\) — the transmon is returned to \(|g\rangle\).
    • Odd \(n\): \(e^{i\pi n} = -1\) — the transmon is mapped to \(|e\rangle\).

For a general cavity state \(\hat{\rho}\), each Fock component contributes with weight \(\langle n|\hat{\rho}|n\rangle\), so the ground-state probability after the sequence is

\[P(|g\rangle) = \sum_n \langle n|\hat{\rho}|n\rangle \cdot \frac{1 + (-1)^n}{2} = \frac{1 + \langle\hat{\Pi}\rangle}{2}\]

which gives the parity expectation value directly:

\[\langle\hat{\Pi}\rangle = P(|g\rangle) - P(|e\rangle) = 2P(|g\rangle) - 1\]

The Wigner function is then recovered as:

\[W(\beta) = \frac{2}{\pi} \langle \hat{\Pi}\rangle = \frac{2}{\pi} \left(2 P(|g \rangle) - 1\right)\]

where \(\langle \hat{\Pi} \rangle = \operatorname{Tr}\!\left[\hat{\rho}\,\hat{D}(\beta)\,\hat{\Pi}\,\hat{D}^\dagger(\beta)\right]\) is the parity expectation value of the displaced cavity state, and \(P(|g\rangle)\) is the probability of finding the transmon in the ground state.

Important

\(R_y\) pulse bandwidth requirement. The \(R_y(\pm\pi/2)\) pulses must rotate the transmon equally for every photon-number component of the cavity state simultaneously. This requires the pulse to be spectrally broad compared with all occupied photon-number-shifted transitions. A drive at \(f_\mathrm{transmon}(0)\) has detuning \(n|\chi|\) from the \(n\)-photon transition, so the condition is:

\[T_{R_y} \ll \frac{1}{N_\mathrm{max}\,|\chi|}\]

In practice this means the \(R_y\) pulse must be shorter than the number-selective \(R_x\) pulses used elsewhere in the tune-up sequence. Using the selective \(R_x\) length directly for the parity mapping is acceptable only when this condition holds. For example, \(|\chi| = 1\,\mathrm{MHz}\) and \(T_{R_y} = 80\,\mathrm{ns}\) give \(T_{R_y} \cdot |\chi| = 0.08\), so the low-\(n\) components are addressed well, but at \(N_\mathrm{max} = 5\) the product \(T_{R_y} \cdot N_\mathrm{max} |\chi| = 0.4\) is only marginally small, and the highest-\(n\) components lose some parity contrast. On devices with larger \(|\chi|\) (typically \(3\)–\(5\,\mathrm{MHz}\)) the degradation is stronger. If you observe a systematic reduction of the Wigner function magnitude at large \(|\beta|\) that cannot be explained by decoherence, shortening selective_transmon_Rx_length (and increasing selective_transmon_X90_amplitudes[0] proportionally to maintain the \(\pi/2\) rotation angle) is the first corrective step to try.

Scan modes

The workflow supports two scan modes:

  1. 1D line cut (beta_im_values=None) — scans along the real axis with \(\mathrm{Im}(\beta) = 0\). This is the fastest scan and is useful for quick diagnostics and verification of state preparation.

  2. 2D phase-space scan (both beta_re_values and beta_im_values provided) — scans a full \(\mathrm{Re}(\beta) \times \mathrm{Im}(\beta)\) grid to produce a complete Wigner tomogram.

Pulse sequence

The pulse sequence per sweep point is:

\[\text{qubit} \;---\; [D(-\beta)] \;---\; [\text{parity mapping}] \;---\; [\text{measure}] \;---\; [\text{passive reset}]\]

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 Wigner tomography 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 to apply the displacement \(D(-\beta)\) at each phase-space point
  • One SG channel for all number-selective transmon drives (drive_transmon_at_n0 … drive_transmon_at_n{max_photon_number}) — used during the parity mapping sequence
  • 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 for 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 Wigner tomography are highlighted in the comments.

# Qubit parameters — adjust these to match your device.
# Parameters marked (*) are directly relevant to the Wigner tomography 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,
    },  # (*) Displacement 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,  # (*) From transmon spectroscopy
    "transmon_drive_range": 10,
    # ── Selective transmon Rx drives (*) ─────────────────────────────
    # These must be calibrated before running Wigner tomography.
    # The Ry(±π/2) pulses in the parity mapping reuse selective_transmon_Rx_length.
    # IMPORTANT: this length must satisfy T_Ry * N_max * |chi| << 1 so the Ry pulses
    # are spectrally broad enough to address all occupied photon-number transitions
    # simultaneously. For |chi| = 1 MHz and T_Ry = 80 ns: T_Ry * |chi| = 0.08, which
    # gives 0.4 at N_max = 5 (marginal for the highest photon numbers).
    # For larger |chi| (3-5 MHz typical), reduce this value and increase
    # selective_transmon_X90_amplitudes[0] proportionally.
    "max_photon_number": MAX_PHOTON_NUMBER,
    "selective_transmon_Rx_length": 0.08e-6,  # (*) Must satisfy T * N_max * |chi| << 1 for parity mapping
    "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),  # (*) Used for Ry(π/2) in parity mapping
    # ── 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 ──────────────────────────────────────────
    "snap_drive_amp_per_n": {},
    "snap_drive_phase_per_n": {},
    "snap_drive_phase_slope_sign_per_n": {},
    # ── 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 wigner_tomography_demonstrator 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 (
    wigner_tomography_demonstrator,
)

Configure the experiment options

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

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

Notice that, unless we change it:

  • The experiment is run in AcquisitionType.DISCRIMINATION and AveragingMode.CYCLIC, using 1024 averages (count). The discrimination acquisition type is required because the Wigner function is extracted from averaged single-shot readout results \(P(|g\rangle)\).
  • The analysis workflow runs automatically after the experiment (do_analysis=True). It computes the parity \(\langle \hat{\Pi} \rangle\) and Wigner function \(W(\beta)\) from the averaged readout signal, and generates the appropriate plots for 1D or 2D scans.
  • The figures produced by the analysis are automatically closed (close_figures=True)

The create_experiment sub-options expose additional settings for advanced use:

  • chunk_count: number of chunks the real-time sweep is split into (default: 1). Increase it if a large 2D scan does not fit into the instrument memory in a single chunk.

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.

options.count(1)
options.close_figures(False)

Mode 1: 1D Line Cut

The 1D mode scans the Wigner function along the real axis (\(\mathrm{Im}(\beta) = 0\)). This is the fastest way to characterise the cavity state and is useful for quick diagnostics — for example, verifying that a displacement has prepared a coherent state, or that the vacuum state is present before an experiment.

Pass beta_re_values and leave beta_im_values as None (the default) to select the 1D mode.

Define the phase-space scan range

Choose a range that covers the expected extent of the cavity state in phase space. For a vacuum state, the Wigner function is a Gaussian of width \(\sim 0.5\) in phase-space units. For a coherent state \(|\beta_0\rangle\), the peak is centred at \(\mathrm{Re}(\beta_0)\). A range of \([-4, 4]\) is appropriate for states with mean photon numbers up to \(\bar{n} \approx 10\).

qubit = qpu.quantum_elements[0]

# 1D phase-space scan along the real axis.
beta_re_q0 = np.linspace(-4, 4, 41)

print(
    f"1D scan: Re(beta) from {beta_re_q0[0]:.1f} to {beta_re_q0[-1]:.1f} "
    f"({len(beta_re_q0)} points)"
)
# Run the 1D line cut on a single qubit.
# beta_im_values defaults to None, selecting the 1D mode.
exp_workflow_1d = wigner_tomography_demonstrator.experiment_workflow(
    session=session,
    qpu=qpu,
    qubits=[qubit.uid],
    beta_re_values=[beta_re_q0],
    options=options,
)

workflow_results_1d = exp_workflow_1d.run()

Inspect the Tasks That Were Run

The Wigner tomography workflow executes five steps in sequence: compute_sweep_parameters, create_experiment, compile_experiment, run_experiment, and (when do_analysis=True) the analysis_workflow, which runs the calculate_wigner_function and plotting tasks and returns the Wigner function data.

for t in workflow_results_1d.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 Wigner tomography sequence consists of a displacement pulse on the drive_memory line (applying \(D(-\beta)\)), followed by the parity mapping sequence on the number-selective transmon drive lines, and a readout pulse.

The parity mapping contains three steps: \(R_y(+\pi/2)\), a free-evolution wait of \(1/(2|\chi|)\), and \(R_y(-\pi/2)\), so

\[t_\mathrm{parity\,mapping} = 2\,t_{R_y} + \frac{1}{2|\chi|}\]

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

\[T \approx t_\mathrm{displacement} + 2\,t_{R_y} + \frac{1}{2|\chi|} + 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  # two Ry(±π/2) pulses
    + 1 / (2 * abs(q.parameters.chi))  # Ramsey wait time
    + q.parameters.readout_length
    + q.parameters.reset_delay_length
)

compiled_experiment_1d = workflow_results_1d.tasks["compile_experiment"].output
plot_simulation(
    compiled_experiment_1d,
    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.

wigner_tomography_demonstrator.create_experiment.src

Analyse the Results (1D)

Because do_analysis=True by default, the analysis workflow ran automatically after the experiment. For the 1D scan it performed the following steps:

  1. Retrieved the averaged discrimination result from the handle {qubit_uid}/result.
  2. Interpreted the averaged discrimination result as \(P(|e\rangle)\) and computed \(P(|g\rangle) = 1 - P(|e\rangle)\).
  3. Computed the parity expectation value \(\langle \hat{\Pi} \rangle = 2 P(|g\rangle) - 1\).
  4. Computed the Wigner function \(W(\beta) = (2/\pi) \langle \hat{\Pi} \rangle\).

One plot is generated automatically for the 1D scan:

  • Left panel — the Wigner function \(W(\beta)\) versus \(\mathrm{Re}(\beta)\), with a filled-area trace.
  • Right panel — the intermediate quantities \(P(|g\rangle)\) and \(\langle \hat{\Pi} \rangle\) versus \(\mathrm{Re}(\beta)\), for diagnostic purposes.

Access the Analysis Outputs

# Access the Wigner function data returned by the analysis workflow.
analysis_result_1d = workflow_results_1d.tasks["analysis_workflow"].output

q_uid = qpu.quantum_elements[0].uid
q_result_1d = analysis_result_1d[q_uid]

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

The per-qubit result dictionary contains:

Key Description
"w" 1D array of Wigner function values \(W(\beta)\), shape (n_re,)
"parity" 1D array of parity expectation values \(\langle \hat{\Pi} \rangle\), shape (n_re,)
"pg" 1D array of ground-state probabilities $P(
"beta_re" 1D array of \(\mathrm{Re}(\beta)\) scan values
"beta_im" None for a 1D scan
"is_2d" False for a 1D scan

Inspect the Wigner Function Values

w_1d = q_result_1d["w"]
beta_re = q_result_1d["beta_re"]

w_max_theory = 2.0 / np.pi

print(f"Qubit {q_uid} (1D scan):")
print(f"  Number of scan points : {len(w_1d)}")
print(f"  W(beta) range         : [{w_1d.min():.4f}, {w_1d.max():.4f}]")
print(
    f"  Theoretical maximum   : {w_max_theory:.4f}  (2/pi, achieved at origin for vacuum)"
)
print(f"  W at Re(beta) = 0     : {w_1d[np.argmin(np.abs(beta_re))]:.4f}")

Mode 2: 2D Phase-Space Scan

The 2D mode scans the full \(\mathrm{Re}(\beta) \times \mathrm{Im}(\beta)\) plane to produce a complete Wigner tomogram. Pass both beta_re_values and beta_im_values to select this mode.

The sweep is constructed as a Cartesian grid: for each qubit, compute_sweep_parameters builds a meshgrid from the two 1D arrays, converts each grid point to polar coordinates \((|\beta|, \arg\beta)\), and flattens the result into a single sweep array. The hardware then sweeps all \((|\beta|, \arg\beta)\) pairs in a single real-time loop.

Define the phase-space grid

For a symmetric scan of the vacuum or a low-amplitude coherent state, a grid of \([-3, 3] \times [-3, 3]\) with 31 points per axis is a reasonable starting point. For higher-photon-number states, extend the range accordingly.

qubit = qpu.quantum_elements[0]

# 2D phase-space grid.
beta_re_2d_q0 = np.linspace(-3, 3, 31)
beta_im_2d_q0 = np.linspace(-3, 3, 31)

print(
    f"2D scan grid: {len(beta_re_2d_q0)} x {len(beta_im_2d_q0)} = "
    f"{len(beta_re_2d_q0) * len(beta_im_2d_q0)} phase-space points"
)
print(f"Re(beta): {beta_re_2d_q0[0]:.1f} to {beta_re_2d_q0[-1]:.1f}")
print(f"Im(beta): {beta_im_2d_q0[0]:.1f} to {beta_im_2d_q0[-1]:.1f}")
# activate auto-chunking
options.chunk_count(0)

# Run the 2D scan on a single qubit.
exp_workflow_2d = wigner_tomography_demonstrator.experiment_workflow(
    session=session,
    qpu=qpu,
    qubits=[qubit.uid],
    beta_re_values=[beta_re_2d_q0],
    beta_im_values=[beta_im_2d_q0],
    options=options,
)

workflow_results_2d = exp_workflow_2d.run()

Analyse the Results (2D)

For the 2D scan, the analysis reshapes the flattened sweep result back into the \(\mathrm{Re}(\beta) \times \mathrm{Im}(\beta)\) grid before computing the Wigner function. Two plots are generated automatically:

  • Wigner function color map — the full phase-space tomogram \(W(\beta)\) displayed as a pcolormesh with a symmetric red–blue colour scale, clipped at the theoretical maximum \(2/\pi\).
  • Line cuts — two panels showing cuts through the origin: \(W(\mathrm{Re}(\beta))\) at \(\mathrm{Im}(\beta) \approx 0\) (left) and \(W(\mathrm{Im}(\beta))\) at \(\mathrm{Re}(\beta) \approx 0\) (right).

Access the Analysis Outputs

analysis_result_2d = workflow_results_2d.tasks["analysis_workflow"].output

q_result_2d = analysis_result_2d[q_uid]
print("Available result keys:", list(q_result_2d.keys()))

The per-qubit result dictionary for a 2D scan contains:

Key Description
"w" 2D array of Wigner function values, shape (n_re, n_im)
"parity" 2D array of parity expectation values, shape (n_re, n_im)
"pg" 2D array of ground-state probabilities, shape (n_re, n_im)
"beta_re" 1D array of \(\mathrm{Re}(\beta)\) grid values
"beta_im" 1D array of \(\mathrm{Im}(\beta)\) grid values
"is_2d" True for a 2D scan

Inspect the 2D Wigner Function

w_2d = q_result_2d["w"]

print(f"Qubit {q_uid} (2D scan):")
print(f"  Wigner function array shape : {w_2d.shape}  (n_re x n_im)")
print(f"  W(beta) range               : [{w_2d.min():.4f}, {w_2d.max():.4f}]")
print(f"  Theoretical maximum         : {2.0 / np.pi:.4f}  (2/pi)")
print(
    f"  W at origin (0, 0)          : {w_2d[len(beta_re_2d_q0) // 2, len(beta_im_2d_q0) // 2]:.4f}"
)

Parallel Wigner Tomography

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

All qubits must produce the same total number of flattened sweep points, because the hardware sweeps them in lock-step. The simplest way to ensure this is to use the same grid shape for every qubit; the actual Re(β) and Im(β) values may differ between qubits.

Here we demonstrate a parallel 2D scan on both qubits, using the same grid:

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

# Each qubit can have its own scan range, but both grids must have the same shape.
beta_re_q0 = np.linspace(-3, 3, 31)
beta_im_q0 = np.linspace(-3, 3, 31)

beta_re_q1 = np.linspace(-3, 3, 31)
beta_im_q1 = np.linspace(-3, 3, 31)

print(
    f"Grid shape: {len(beta_re_q0)} x {len(beta_im_q0)} = "
    f"{len(beta_re_q0) * len(beta_im_q0)} sweep points per qubit"
)

exp_workflow_2q = wigner_tomography_demonstrator.experiment_workflow(
    session=session,
    qpu=qpu,
    qubits=[q0.uid, q1.uid],
    beta_re_values=[beta_re_q0, beta_re_q1],
    beta_im_values=[beta_im_q0, beta_im_q1],
    options=options,
)

workflow_results_2q = exp_workflow_2q.run()

Access the Analysis Results for Both Qubits

analysis_result_2q = workflow_results_2q.tasks["analysis_workflow"].output

for q in [q0, q1]:
    res = analysis_result_2q[q.uid]
    w = res["w"]
    print(
        f"{q.uid}: W(beta) shape = {w.shape}, "
        f"range = [{w.min():.4f}, {w.max():.4f}], "
        f"W at origin = {w[len(beta_re_q0) // 2, len(beta_im_q0) // 2]:.4f}"
    )

Advanced: Overriding the Color Scale

In practice, decoherence and readout infidelity reduce the contrast of the measured Wigner function. The theoretical maximum \(2/\pi \approx 0.6366\) is rarely achieved experimentally. To improve the visual contrast of the plot, you can override the color scale using the w_max_override analysis option.

This is an option of the Wigner function plotting tasks (PlotWignerFunctionOptions), which you can set from the workflow options object:

# Override the colour scale to match the actual experimental contrast.
# Here we assume the measured Wigner function peaks at ~0.1 due to decoherence.
options_scaled = wigner_tomography_demonstrator.experiment_workflow.options()
options_scaled.count(1)
options_scaled.close_figures(False)
options_scaled.w_max_override(0.1)
options_scaled.chunk_count(0)

exp_workflow_scaled = wigner_tomography_demonstrator.experiment_workflow(
    session=session,
    qpu=qpu,
    qubits=[qubit.uid],
    beta_re_values=[beta_re_2d_q0],
    beta_im_values=[beta_im_2d_q0],
    options=options_scaled,
)

workflow_results_scaled = exp_workflow_scaled.run()

Great! You have now run Wigner tomography on your bosonic qubit's memory cavity.

The Wigner function encodes the complete quantum state of the cavity in phase space. Key signatures to look for:

  • Vacuum state \(|0\rangle\): a Gaussian peak centred at the origin with \(W(0) = 2/\pi\).
  • Coherent state \(|\beta_0\rangle\): a Gaussian peak displaced from the origin to position \(\beta_0\) in phase space.
  • Fock state \(|n\rangle\): a circularly symmetric pattern with \(n\) rings of oscillating sign. \(W(0) = (2/\pi)(-1)^n\), which is negative for odd-photon Fock states — a direct signature of non-classicality.
  • Cat states: interference fringes between two displaced Gaussian peaks, with alternating positive and negative regions at the midpoint.

For a comparison with theoretical predictions and further analysis of specific cavity states, see also the SNAP Gate Calibration how-to guide, which calibrates the gates used to prepare non-classical cavity states.