Platforms#

Qibolab provides support to different quantum laboratories.

Each lab configuration is implemented using a qibolab.Platform object which orchestrates instruments, qubits and channels and provides the basic features for executing pulses. Therefore, the Platform enables the user to interface with all the required lab instruments at the same time with minimum effort.

The API reference section provides a description of all the attributes and methods of the Platform. Here, let’s focus on the main elements.

In the platform, the main methods can be divided in different sections:

  • functions to coordinate the instruments (connect, disconnect)

  • a unique interface to execute experiments (execute)

  • functions save parameters (dump)

The idea of the Platform is to serve as the only object exposed to the user, so that we can deploy experiments, without any need of going into the low-level instrument-specific code.

For example, let’s first define a platform (that we consider to be a single qubit platform) using the create method presented in How to connect Qibolab to your lab?:

from qibolab import create_platform

platform = create_platform("dummy")

Now we connect to the instruments (note that we, the user, do not need to know which instruments are connected).

platform.connect()

We can easily access the names of channels and other components, and based on the name retrieve the corresponding configuration. As an example let’s print some things:

Note

If requested component does not exist in a particular platform, its name will be None, so watch out for such names, and make sure what you need exists before requesting its configuration.

drive_channel_id = platform.qubits[0].drive
drive_channel = platform.channels[drive_channel_id]
print(f"Drive channel name: {drive_channel_id}")
print(f"Drive frequency: {platform.config(drive_channel_id).frequency}")

drive_lo = drive_channel.lo
if drive_lo is None:
    print(f"Drive channel {drive_channel_id} does not use an LO.")
else:
    print(f"Name of LO for channel {drive_channel_id} is {drive_lo}")
    print(f"LO frequency: {platform.config(drive_lo).frequency}")

Now we can create a simple sequence without explicitly giving any qubit specific parameter, as these are loaded automatically from the platform, as defined in the corresponding parameters.json:

from qibolab import Delay, PulseSequence
import numpy as np

ps = PulseSequence()
qubit = platform.qubits[0]
natives = platform.natives.single_qubit[0]
ps.concatenate(natives.RX())
ps.concatenate(natives.R(phi=np.pi / 2))
ps.append((qubit.probe, Delay(duration=200)))
ps.concatenate(natives.MZ())

Now we can execute the sequence on hardware:

from qibolab import (
    AcquisitionType,
    AveragingMode,
)

options = dict(
    nshots=1000,
    relaxation_time=10,
    fast_reset=False,
    acquisition_type=AcquisitionType.INTEGRATION,
    averaging_mode=AveragingMode.CYCLIC,
)
results = platform.execute([ps], **options)

Finally, we can stop instruments and close connections.

platform.disconnect()

Dummy platform#

In addition to the real instruments presented in the Instruments section, Qibolab provides the qibolab.instruments.DummyInstrument. This instrument represents a controller that returns random numbers of the proper shape when executing any pulse sequence. This instrument is also part of the dummy platform which is defined in qibolab._core.dummy and can be initialized as

from qibolab import create_platform

platform = create_platform("dummy")

This platform is equivalent to real platforms in terms of attributes and functions, but returns just random numbers. It is useful for testing parts of the code that do not necessarily require access to an actual quantum hardware platform.

Channels#

Channels play a pivotal role in connecting the quantum system to the control infrastructure. Various types of channels are typically present in a quantum laboratory setup, including:

  • the probe line (from device to qubit)

  • the acquire line (from qubit to device)

  • the drive line

  • the flux line

  • the TWPA pump line

Qibolab provides a general qibolab.Channel object, as well as specializations depending on the channel role. A channel is typically associated with a specific port on a control instrument, with port-specific properties like “attenuation” and “gain” that can be managed using provided getter and setter methods. Channels are uniquely identified within the platform through their id.

The idea of channels is to streamline the pulse execution process. The qibolab.PulseSequence is a list of (channel_id, pulse) tuples, so that the platform identifies the channel that every pulse plays and directs it to the appropriate port on the control instrument.

In setups involving frequency-specific pulses, a local oscillator (LO) might be required for up-conversion. Although logically distinct from the qubit, the LO’s frequency must align with the pulse requirements. Qibolab accommodates this by enabling the assignment of a qibolab._core.instruments.oscillator.LocalOscillator object to the relevant channel qibolab.IqChannel. The controller’s driver ensures the correct pulse frequency is set based on the LO’s configuration.

Each channel has a qibolab._core.components.configs.Config associated to it, which is a container of parameters related to the channel. Configs also have different specializations that correspond to different channel types. The platform holds default config parameters for all its channels, however the user is able to alter them by passing a config updates dictionary when calling qibolab.Platform.execute(). The final configs are then sent to the controller instrument, which matches them to channels via their ids and ensures they are uploaded to the proper electronics.

Qubits#

The qibolab.Qubit class serves as a container for the channels that are used to control the corresponding physical qubit. These channels encompass distinct types, each serving a specific purpose:

  • probe (measurement probe from controller device to the qubits)

  • acquisition (measurement acquisition from qubits to controller)

  • drive

  • flux

  • drive_qudits (additional drive channels at different frequencies used to probe higher-level transition)

Some channel types are optional because not all hardware platforms require them. For example, flux channels are typically relevant only for flux tunable qubits.

The qibolab.Qubit class can also be used to represent coupler qubits, when these are available.

Pulses#

In Qibolab, an extensive API is available for working with pulses and pulse sequences, a fundamental aspect of quantum experiments. At the heart of this API is the qibolab.Pulse object, which empowers users to define and customize pulses with specific parameters.

Additionally, pulses are defined by an envelope shape, represented by a subclass of qibolab._core.pulses.envelope.BaseEnvelope. Qibolab offers a range of pre-defined pulse shapes which can be found in qibolab._core.pulses.envelope.

To illustrate, here is an examples of how to instantiate a pulse using the Qibolab API:

from qibolab import Pulse, Rectangular

pulse = Pulse(
    duration=40.0,  # Pulse duration in ns
    amplitude=0.5,  # Amplitude normalized to [-1, 1]
    relative_phase=0.0,  # Phase in radians
    envelope=Rectangular(),
)

Here, we defined a rectangular drive pulse using the generic Pulse object.

Both the Pulses objects and the PulseShape object have useful plot functions and several different various helper methods.

To organize pulses into sequences, Qibolab provides the qibolab.PulseSequence object. Here’s an example of how you can create and manipulate a pulse sequence:

from qibolab import Pulse, PulseSequence, Rectangular


pulse1 = Pulse(
    duration=40,  # timing, in all qibolab, is expressed in ns
    amplitude=0.5,  # this amplitude is relative to the range of the instrument
    relative_phase=0,  # phases are in radians
    envelope=Rectangular(),
)
pulse2 = Pulse(
    duration=40,  # timing, in all qibolab, is expressed in ns
    amplitude=0.5,  # this amplitude is relative to the range of the instrument
    relative_phase=0,  # phases are in radians
    envelope=Rectangular(),
)
pulse3 = Pulse(
    duration=40,  # timing, in all qibolab, is expressed in ns
    amplitude=0.5,  # this amplitude is relative to the range of the instrument
    relative_phase=0,  # phases are in radians
    envelope=Rectangular(),
)
pulse4 = Pulse(
    duration=40,  # timing, in all qibolab, is expressed in ns
    amplitude=0.5,  # this amplitude is relative to the range of the instrument
    relative_phase=0,  # phases are in radians
    envelope=Rectangular(),
)
sequence = PulseSequence(
    [
        ("qubit/drive", pulse1),
        ("qubit/drive", pulse2),
        ("qubit/drive", pulse3),
        ("qubit/drive", pulse4),
    ],
)

print(f"Total duration: {sequence.duration}")

When conducting experiments on quantum hardware, pulse sequences are vital. Assuming you have already initialized a platform, executing an experiment is as simple as:

result = platform.execute([sequence])

Lastly, when conducting an experiment, it is not always required to define a pulse from scratch. Usual pulses, such as pi-pulses or measurements, are already defined in the platform runcard and can be easily initialized with platform methods. These are relying on parameters held in the Native data structures. Typical experiments may include both pre-defined pulses and new ones:

from qibolab import Rectangular

natives = platform.natives.single_qubit[0]
sequence = natives.RX() | natives.MZ()

results = platform.execute([sequence])

Sweepers#

Sweeper objects, represented by the qibolab.Sweeper class, stand as a crucial component in experiments and calibration tasks within the Qibolab framework.

Consider a scenario where a resonator spectroscopy experiment is performed. This process involves a sequence of steps:

  1. Define a pulse sequence.

  2. Define a readout pulse with frequency \(A\).

  3. Execute the sequence.

  4. Define a new readout pulse with frequency \(A + \epsilon\).

  5. Execute the sequence again.

  6. Repeat for increasing frequencies \(A + 2 \epsilon\), \(A + 3 \epsilon\), and so on.

This approach is suboptimal and time-consuming, mainly due to the frequent communication between the control device and the Qibolab user after each execution. Such communication overhead significantly extends experiment duration.

In supported control devices, an efficient technique involves defining a “sweeper” or a parameter scan directly on the device. This scan, applied to specific parameters, allows multiple variations to be executed in a single communication round, drastically reducing experiment time.

To address the inefficiency, Qibolab introduces the concept of Sweeper objects.

Sweeper objects in Qibolab are characterized by a qibolab.Parameter. This parameter, crucial to the sweeping process, can be one of several types:

  • Amplitude

  • Duration

  • Relative_phase

  • Start

  • Frequency

  • Offset

The first group includes parameters of the pulses, while the second group includes parameters of channels.

To designate the pulse(s) or channel(s) to which a sweeper is applied, you can utilize the pulses or channels parameter within the Sweeper object.

Note

It is possible to simultaneously execute the same sweeper on different pulses or channels. The pulses or channels attribute is designed as a list, allowing for this flexibility.

To effectively specify the sweeping behavior, Qibolab provides the values attribute along with the type attribute.

The values attribute comprises an array of numerical values that define the sweeper’s progression.

Let’s see some examples. Consider now a system with three qubits (qubit 0, qubit 1, qubit 2) with resonator frequency at 4 GHz, 5 GHz and 6 GHz. A typical resonator spectroscopy experiment could be defined with:

import numpy as np

from qibolab import Parameter, Sweeper

natives = platform.natives.single_qubit

sequence = (
    natives[0].MZ()  # readout pulse for qubit 0 at 4 GHz
    | natives[1].MZ()  # readout pulse for qubit 1 at 5 GHz
    | natives[2].MZ()  # readout pulse for qubit 2 at 6 GHz
)

sweepers = [
    Sweeper(
        parameter=Parameter.frequency,
        values=platform.config(qubit.probe).frequency
        + np.arange(-200_000, +200_000, 1),  # define an interval of swept values
        channels=[qubit.probe],
    )
    for qubit in platform.qubits.values()
]

results = platform.execute([sequence], [sweepers], **options)
In this way, we first define three parallel sweepers with an interval of 400 MHz (-200 MHz — 200 MHz). The resulting probed frequency will then be:
  • for qubit 0: [3.8 GHz, 4.2 GHz]

  • for qubit 1: [4.8 GHz, 5.2 GHz]

  • for qubit 2: [5.8 GHz, 6.2 GHz]

It is possible to define and executes multiple sweepers at the same time, in a nested loop style. For example:

qubit = platform.qubits[0]
natives = platform.natives.single_qubit[0]
rx_sequence = natives.RX()
sequence = rx_sequence | natives.MZ()

f0 = platform.config(qubit.drive).frequency
sweeper_freq = Sweeper(
    parameter=Parameter.frequency,
    range=(f0 - 100_000, f0 + 100_000, 10_000),
    channels=[qubit.drive],
)
rx_pulse = rx_sequence[0][1]
sweeper_amp = Sweeper(
    parameter=Parameter.amplitude,
    range=(0, 0.43, 0.3),
    pulses=[rx_pulse],
)

results = platform.execute([sequence], [[sweeper_freq], [sweeper_amp]], **options)

Let’s say that the RX pulse has, from the runcard, a frequency of 4.5 GHz and an amplitude of 0.3, the parameter space probed will be:

  • amplitudes: [0, 0.03, 0.06, 0.09, 0.12, …, 0.39, 0.42]

  • frequencies: [4.4999, 4.49991, 4.49992, …., 4.50008, 4.50009] (GHz)

Sweepers given in the same list will be applied in parallel, in a Python zip style, while different lists define nested loops, with the first list corresponding to the outer loop.

Warning

Different control devices may have different limitations on the sweepers. It is possible that the sweeper will raise an error, if not supported, or that it will be automatically converted as a list of pulse sequences to perform sequentially.

Execution Parameters#

In the course of several examples, you’ve encountered the **options argument in function calls like:

res = platform.execute([sequence], **options)

Let’s now delve into the details of the options and understand its parts.

The options extra arguments, is a vital element for every hardware execution. It encompasses essential information that tailors the execution to specific requirements:

  • nshots: Specifies the number of experiment repetitions.

  • relaxation_time: Introduces a wait time between repetitions, measured in nanoseconds (ns).

  • fast_reset: Enables or disables fast reset functionality, if supported; raises an error if not supported.

  • acquisition_type: Determines the acquisition mode for results.

  • averaging_mode: Defines the mode for result averaging.

The first three parameters are straightforward in their purpose. However, let’s take a closer look at the last two parameters.

Supported acquisition types, accessible via the qibolab.AcquisitionType enumeration, include:

  • Discrimination: Distinguishes states based on acquired voltages.

  • Integration: Returns demodulated and integrated waveforms.

  • Raw: Offers demodulated, yet unintegrated waveforms.

Supported averaging modes, available through the qibolab.AveragingMode enumeration, consist of:

  • Cyclic: Provides averaged results, yielding a single IQ point per measurement.

  • Singleshot: Supplies non-averaged results.

Note

Two averaging modes actually exists: cyclic and sequential. In sequential mode, a sweeper is executed with the repetition loop nested inside, while cyclic mode places the sweeper as the outermost loop. Cyclic execution generally offers better noise resistance. Ideally, use the cyclic mode. However, some devices lack support for it and will automatically convert it to sequential execution.

Results#

platform.execute returns a dictionary, mapping the acquisition pulse id to the results of the corresponding measurements. The results of each measurement are a numpy array with dimension that depends on the number of shots, acquisition type, averaging mode and the number of swept points, if sweepers were used.

For example in

qubit = platform.qubits[0]
natives = platform.natives.single_qubit[0]

ro_sequence = natives.MZ()
sequence = natives.RX() | ro_sequence


ro_pulse = ro_sequence[0][1]
result = platform.execute(
    [sequence],
    nshots=1000,
    relaxation_time=10,
    fast_reset=False,
    acquisition_type=AcquisitionType.INTEGRATION,
    averaging_mode=AveragingMode.CYCLIC,
)

result will be a dictionary with a single key ro_pulse.id and an array of two elements, the averaged I and Q components of the integrated signal. If instead, (AcquisitionType.INTEGRATION, AveragingMode.SINGLESHOT) was used, the array would have shape (options["nshots"], 2), while for (AcquisitionType.DISCRIMINATION, AveragingMode.SINGLESHOT) the shape would be (options["nshots"],) with values 0 or 1.

The shape of the values of an integrated acquisition with two sweepers will be:

f0 = platform.config(qubit.drive).frequency
sweeper1 = Sweeper(
    parameter=Parameter.frequency,
    range=(f0 - 100_000, f0 + 100_000, 1),
    channels=[qubit.drive],
)
sweeper2 = Sweeper(
    parameter=Parameter.frequency,
    range=(f0 - 200_000, f0 + 200_000, 1),
    channels=[qubit.probe],
)
shape = (options["nshots"], len(sweeper1.values), len(sweeper2.values), 2)

Transpiler and Compiler#

While pulse sequences can be directly deployed using a platform, circuits need to first be transpiled and compiled to the equivalent pulse sequence. This procedure typically involves the following steps:

  1. The circuit needs to respect the chip topology, that is, two-qubit gates can only target qubits that share a physical connection. To satisfy this constraint SWAP gates may need to be added to rearrange the logical qubits.

  2. All gates are transpiled to native gates, which represent the universal set of gates that can be implemented (via pulses) in the chip.

  3. Native gates are compiled to a pulse sequence.

The transpiler is responsible for steps 1 and 2, while the compiler for step 3 of the list above. To be executed in Qibolab, a circuit should be already transpiled. It possible to use the transpilers provided by Qibo to do it. For more information, please refer the examples in the Qibo documentation. On the other hand, the compilation process is taken care of automatically by the qibolab.QibolabBackend.

Once a circuit has been compiled, it is converted to a qibolab.PulseSequence by the qibolab._core.compilers.compiler.Compiler. This is a container of rules which define how each native gate can be translated to pulses. A rule is a Python function that accepts a Qibo gate and a platform object and returns the qibolab.PulseSequence implementing this gate and a dictionary with potential virtual-Z phases that need to be applied in later pulses. Examples of rules can be found on qibolab._core.compilers.default, which defines the default rules used by Qibolab.

Note

Rules return a qibolab.PulseSequence for each gate, instead of a single pulse, because some gates such as the U3 or two-qubit gates, require more than one pulses to be implemented.

Native#

Each quantum platform supports a specific set of native gates, which are the quantum operations that have been calibrated. If this set is universal any circuit can be transpiled and compiled to a pulse sequence which can then be deployed in the given platform.

qibolab._core.native provides data containers for holding the pulse parameters required for implementing every native gate. The qibolab.Platform provides a natives property that returns the qibolab._core.native.SingleQubitNatives which holds the single qubit native gates for every qubit and qibolab._core.native.TwoQubitNatives for the two-qubit native gates of every qubit pair. Each native gate is represented by a qibolab.PulseSequence which contains all the calibrated parameters.

Typical single-qubit native gates are the Pauli-X gate, implemented via a pi-pulse which is calibrated using Rabi oscillations and the measurement gate, implemented via a pulse sent in the readout line followed by an acquisition. For a universal set of single-qubit gates, the RX90 (pi/2-pulse) gate is required, which is implemented by halving the amplitude of the calibrated pi-pulse.

Typical two-qubit native gates are the CZ and iSWAP, with their availability being platform dependent. These are implemented with a sequence of flux pulses, potentially to multiple qubits, and virtual Z-phases. Depending on the platform and the quantum chip architecture, two-qubit gates may require pulses acting on qubits that are not targeted by the gate.

Instruments#

One the key features of Qibolab is its support for multiple different electronics. A list of all the supported electronics follows:

Controllers (subclasses of qibolab._core.instruments.abstract.Controller):
Other Instruments (subclasses of qibolab._core.instruments.abstract.Instrument):

All instruments inherit the qibolab._core.instruments.abstract.Instrument and implement methods for connecting and disconnecting. qibolab._core.instruments.abstract.Controller is a special case of instruments that provides the qibolab._core.instruments.abstract.execute method that deploys sequences on hardware.

Some more detail on the interal functionalities of instruments is given in How to add a new instrument in Qibolab?

The following is a table of the currently supported or not supported features (dev stands for under development):

Supported features#

Feature

RFSoC

Qblox

QM

ZH

Arbitrary pulse sequence

yes

yes

yes

yes

Arbitrary waveforms

yes

yes

yes

yes

Multiplexed readout

yes

yes

yes

yes

Hardware classification

no

yes

yes

yes

Fast reset

dev

dev

dev

dev

Device simulation

no

no

yes

dev

RTS frequency

yes

yes

yes

yes

RTS amplitude

yes

yes

yes

yes

RTS duration

yes

yes

yes

yes

RTS relative phase

yes

yes

yes

yes

RTS 2D any combination

yes

yes

yes

yes

Sequence unrolling

dev

dev

dev

dev

Hardware averaging

yes

yes

yes

yes

Singleshot (no averaging)

yes

yes

yes

yes

Integrated acquisition

yes

yes

yes

yes

Classified acquisition

yes

yes

yes

yes

Raw waveform acquisition

yes

yes

yes

yes

Zurich Instruments#

Qibolab has been tested with the following instrument cluster:

  • 1 SHFQC (Superconducting Hybrid Frequency Converter)

  • 2 HDAWGs (High-Density Arbitrary Waveform Generators)

  • 1 PQSC (Programmable Quantum System Controller)

The integration of Qibolab with the instrument cluster is facilitated through the LabOneQ Python library that handles communication and coordination with the instruments.

Quantum Machines#

Tested with a cluster of nine OPX+ controllers, using QOP213 and QOP220.

Qibolab is communicating with the instruments using the QUA language, via the qm-qua and qualang-tools Python libraries.