Custom Gates and Gate Implementations#

This notebook demonstrates how to work with user-defined quantum operations and gate implementations that go beyond the standard gate set.

Each quantum operation is associated with at least one GateImplementation which translates circuit-level concepts to lower-level instructions accepted by IQM Server. This example shows how the user can

  • Select a non-default implementation for a gate

  • Add a custom implementation for an existing gate

  • Add a custom gate and implementation by using existing gates as building blocks (composite gates)

  • Define new pulse waveforms for implementations

Environment setup#

Please refer to the Quick Start.ipynb for basic usage, terminology, and environment setup. It is recommended to run also Configuration and Usage.ipynb and Sweeps.ipynb before this notebook.

import os
from pprint import pprint
from IPython.core.display import HTML
import numpy as np
import matplotlib.pyplot as plt
from dataclasses import dataclass

from qiskit import QuantumCircuit
from qiskit.compiler import transpile

from iqm.qiskit_iqm import IQMProvider
from iqm.qiskit_iqm.iqm_transpilation import optimize_single_qubit_gates
from iqm.pulla.pulla import Pulla
from iqm.pulla.utils_qiskit import qiskit_to_pulla, get_qiskit_compiler
from iqm.pulse.gates import PRX_Samples
from iqm.pulse.gates.prx import PRX_CustomWaveforms
from iqm.pulse.playlist.waveforms import Waveform
from iqm.pulse.gate_implementation import CompositeGate
from iqm.pulse.quantum_ops import QuantumOp
from iqm.pulse.playlist.visualisation.base import inspect_playlist
from exa.common.control.sweep.sweep import Sweep
from exa.common.control.sweep.option import CenterSpanOptions, StartStopOptions
iqm_server_url = os.environ['PULLA_IQM_SERVER_URL']  # or set the URL directly here
os.environ["IQM_TOKEN"] = os.environ.get("IQM_TOKEN")  # or set the token directly here

pulla = Pulla(iqm_server_url)

provider = IQMProvider(iqm_server_url)
backend = provider.get_backend()
def example_qiskit_circuit():
    qc = QuantumCircuit(2, 2)
    qc.x(0)
    qc.cx(0, 1)
    qc.h(0)
    qc.measure_all()

    transpiled_qc = transpile(qc, backend=backend, layout_method='sabre', optimization_level=3)
    transpiled_qc = optimize_single_qubit_gates(transpiled_qc)
    return transpiled_qc

example_qiskit_circuit().draw(output='mpl', style='clifford', idle_wires=False)
pulla.get_standard_compiler().show_stages()

Custom gates: Composite gates#

IQM Pulse allows the user to define composite gates: gates consisting of other registered gates. Composite gates are particularly useful because they allow reusing the calibrated of data of the other gates. Furthermore, it is possible to use unique calibration data for the registered gates inside a composite gate.

Define a prx implementation that acts like a normal prx, except it implements X with 2 pulses with a 100 ns wait between them. The __call__ method produces a TimeBox using IQM Pulse’s ScheduleBuilder. It is worth mentioning that the composite gate is not restricted to using only registered gates; it could equally well return a TimeBox with lower level instructions.

class StretchedX(CompositeGate):
    registered_gates = ('prx',)  # Use the standard prx as a building block

    def __call__(self, angle: float, phase: float):
        normal_prx = self.build("prx", self.locus)
        if angle != np.pi:
            return normal_prx(angle, phase)

        half = normal_prx(np.pi / 2, phase)
        return half + self.builder.wait(self.locus, 100e-9, rounding=True) + half

Register a new gate custom_x, which StretchedX implements, and make it compatible with the circuit-level prx, by declaring it having the same parameters. Then change the first prx in the IQM Pulse circuit to use the new implementation.

circuits, compiler = qiskit_to_pulla(pulla, backend, example_qiskit_circuit())
compiler.add_implementation('custom_x', 'StretchedX', StretchedX, quantum_op=QuantumOp('custom_x', params={'angle': (float,), 'phase': (float,)}))

c = circuits[0]
for inst in c.instructions:
    if inst.name == 'prx':
        inst.name = 'custom_x'

job_definition, _ = compiler.compile(circuits)

Inspecting the schedule, the X gate in the circuit is indeed split into two pulses with a wait in between:

HTML(inspect_playlist(job_definition.sweep_definition.playlist, [0]))

Custom gates: Custom waveforms#

Next, change the pulse waveforms of a PRX gate.

The quickest way to test how some waveform performs is probably to just provide np.array samples and use the dedicated custom samples PRX implementation PRX_Samples. However, providing some samples is not enough to actually use the new waveform. At least the I amplitude needs to be calibrated. This can be done very easily by reproducing the Rabi experiment.

Defining a triangular waveform, one can indeed calibrate a PRX gate with it. Probably not as good as the PRX waveform, but Pulla is allowing for any highly customized waveforms.

# 40 points rising, 40 points falling
# NOTE: the number of samples define the duration of the pulse via the sampling rate (2GHz usually).
y_up = np.linspace(0, 1, 40, endpoint=False)
y_down = np.linspace(1, 0, 40)

y = np.concatenate((y_up, y_down))

plt.plot(y)
compiler = get_qiskit_compiler(pulla, backend)

# Rabi circuit
qc = QuantumCircuit(1,1)
qc.x(0)
qc.measure(0,0)

compiler.add_implementation("prx", "samples", PRX_Samples)  # register a custom samples implementation of PRX
settings = compiler.get_settings(circuits=[qc])
settings.gate_definitions.prx.default_implementation = "samples"  # set the samples implementaion as the default
settings.gates.prx.samples.QB1.set_from_dict({  # provide the triangular samples as calibration data
    "amplitude_q": 0.0,  # we don't worry about calibrating Q amplitude here
    "i":{"samples": y},
    "q": {"samples": np.zeros(80)}
})
# run Rabi experiment to determine the amplitude_i
amp_sweep = Sweep(parameter=settings.gates.prx.samples.QB1.amplitude_i.parameter, data=StartStopOptions(0.0, 1.0, 100).data)
job_definition, context = compiler.compile(circuits=[qc], components=["QB1"], settings=settings, sweeps=[amp_sweep])
job = pulla.submit_playlist(job_definition, context=context)
job.wait_for_completion()
result = job.result(compiler)
# plot the QB1 e state probability
result.dataset["QB1__c_1_0_0_excited_state_probability"].plot()

Next, pick the amplitude value corresponding to the half amplitude of the shown Rabi oscillations, and set it as the I amplitude via settings.gates.prx.samples.QB1.amplitude_i = <half amplitude value>.

Parametrized waveforms#

Most of the canonical gate implementations are not defined in terms of samples, but instead parametrized. How this works is that the gate implementation has the calibration parameter duration (see e.g. settings.gates.prx.drag_crf.QB1) given in seconds. And the waveform itself may have any number of additional parameters that define its form (e.g. settings.gates.prx.drag_crf.QB1.full_width). Users can of course also create a parametrized waveform and use it in the PRX gate.

The easiest way to do this is to inherit from the PRX_CustomWaveforms implementation while defining the I and Q parametrized waveforms.

@dataclass(frozen=True)
class RaisedCosine(Waveform):
    r"""Raised cosine pulse.

    .. math::
        f(t) = \frac{1}{2} (1 + \cos((t - c) \pi / w)) \quad \text{when} \: |t - c| \le w, 0 \: \text{otherwise}

    where :math:`c` is :attr:`center_offset`, and :math:`w` is :attr:`width`.
    The raised cosine has a finite support.

    Args:
        width: half-width of the support
        center_offset: center offset
    """

    width: float
    center_offset: float = 0.0

    def _sample(self, sample_coords: np.ndarray) -> np.ndarray:
        offset_coords = sample_coords - self.center_offset
        return np.where(
            np.abs(offset_coords) <= self.width,
            0.5 * (1 + np.cos(offset_coords * np.pi / self.width)),
            0.0,
        )


@dataclass(frozen=True)
class RaisedCosineDerivative(Waveform):
    r"""Scaled derivative of the RaisedCosine pulse.

    .. math::
        f(t) = -\sin((t - c) \pi / w) \quad \text{when} \: |t - c| \le w, 0 \: \text{otherwise}

    where :math:`c` is :attr:`center_offset`, and :math:`w` is :attr:`width`.

    Args:
        width: half-width of the support
        center_offset: center offset
    """

    width: float
    center_offset: float = 0.0

    def _sample(self, sample_coords: np.ndarray) -> np.ndarray:
        offset_coords = sample_coords - self.center_offset
        return np.where(
            np.abs(offset_coords) <= self.width,
            -0.5 * np.sin(offset_coords * np.pi / self.width),
            0.0,
        )


class PRX_RaisedCosine(PRX_CustomWaveforms, wave_i=RaisedCosine, wave_q=RaisedCosineDerivative):
    """Implementation of PRX using a raised cosine pulse."""

    center_offset: float = 0.0

The class attributes of the waveforms define the calibration data they require. The PRX_CustomWaveforms class adds some more. Add the new prx implementation to the compiler to check what calibration data it needs:

circuits, compiler = qiskit_to_pulla(pulla, backend, example_qiskit_circuit())

compiler.add_implementation('prx', 'raised_cosine', PRX_RaisedCosine)
settings = compiler.get_settings(circuits=example_qiskit_circuit())
settings.gates.prx.raised_cosine.QB1

Make one of the prx gates in the circuit use our new implementation:

circuits[0].instructions[0].implementation = "raised_cosine"

Compiling this circuit right now would fail with an error:

Error in stage "circuit" pass "map_implementations_for_loci": Circuit 0: No calibration data for 'prx.raised_cosine' at ('QB1',).

(The locus (‘QB1’,) may differ in your output due to the stochastic nature of routing.)

Of course the gate needs to be calibrated, see the prevous example of Rabi experiment on a triangular wave. Provided some placeholder calibration data:

custom_cal_data = {
    "duration": 40e-09,
    "amplitude_i": 0.1662,
    "amplitude_q": -0.00802,
    "width": 20e-9,
    "center_offset": 3e-9,
}

for qubit in compiler.chip_topology.qubits:
    settings.gates.prx.raised_cosine[qubit].set_from_dict(custom_cal_data)

Now the compilation succeeds and raised_cosine was used once. The schedule visualization allows to verify that the pulse shape is indeed different on the first prx instance.

playlist, context = compiler.compile(circuits, settings=settings)
pprint(context['circuit_metrics'][0].gate_loci["prx"])