See also

A Jupyter notebook version of this tutorial can be downloaded here.

Acquisitions#

Introduction#

This tutorial provides examples of how to add acquisitions to schedules and retrieve the corresponding results.

The fundamental data structure returned by an acquisition is an xarray.Dataset (official documentation), which consists of multiple xarray.DataArray.

Initial setup#

First, we set up the connection to the cluster and hardware configuration.

The introduction and parametrization of a qubit is not mandatory for running acquisitions. Hence schedules presented in this tutorial will remain at the pulse-level description, and we only define the hardware_configuration and not the quantum_device_configuration.

[1]:
from typing import Any

import xarray

from qblox_scheduler import HardwareAgent
[2]:
hardware_config: dict[str, Any] = {
    "version": "0.2",
    "config_type": "QbloxHardwareCompilationConfig",
    "hardware_description": {
        "cluster0": {
            "instrument_type": "Cluster",
            "modules": {"4": {"instrument_type": "QRM"}},
            "ref": "internal",
            "ip": "10.10.200.42",
        }
    },
    "hardware_options": {},
    "connectivity": {
        "graph": [
            ["cluster0.module4.complex_output_0", "q0:port"],
            ["cluster0.module4.complex_input_0", "q0:port"],
        ]
    },
}

hardware_agent: HardwareAgent = HardwareAgent(hardware_configuration=hardware_config)
cluster = hardware_agent.get_clusters()["cluster0"]

Pulse-level acquisitions#

Pulse-level acquisitions define when and how to store and retrieve input signals coming from the device. They are described by their timing information (start time and length), protocol, acq_label and coordinates.

  • Timing information specifies when the acquisition happens on the schedule.

  • Protocol defines which hardware or software formatting is done on the input signal.

  • acq_label and coordinates together identify acquisitions and data points for the user in the returned dataset: acq_label identifies a particular DataArray, while every point within such an array is identified by automatically-generated indices and assigned coordinates. These assigned coordinates are user-defined tags attached to the acquisitions which can be used for differentiating acquisitions or reshaping data arrays for further analysis.

In the following subsections we give examples for the supported acquisition protocols.

We assume, that these tutorials are run on a Qblox QRM module. On the QRM module \(\text{O}^{[1]}\) is connected to \(\text{I}^{[1]}\) and \(\text{O}^{[2]}\) is connected to \(\text{I}^{[2]}\).

The physical length of the cables connecting output to input channels introduces a delay before a sent signal is received. This delay is known as the TIME_OF_FLIGHT.

Trace acquisition#

One of the simplest protocols is the trace (or scope) acquisition. With this protocol, one can acquire the time trace of a waveform, just like with an oscilloscope. The sampling rate is 1 GSa/s, and the maximum duration of a single Trace acquisition is 16 us. In this subsection we will send out a DRAG pulse on the output, then measure it with the input of the QRM, and plot the retrieved data.

Setting up the schedule#

The schedule is straightforward: it emits a pulse on an output port and then initiates a trace acquisition on the corresponding input port, delayed by TIME_OF_FLIGHT to account for the signal travel time.

We choose to play a DRAG pulse, whose duration is defined using the parameter PULSE_DURATION. We define a separate parameter ACQ_DURATION, allowing the acquisition duration to be larger than the duration of the pulse (e.g., in case the TIME_OF_FLIGHT was not yet calibrated).

[3]:
# parameters for the experiment
# --------------------------------
TIME_OF_FLIGHT: float = 148e-9
PULSE_DURATION: float = 1e-6
PULSE_AMPLITUDE: float = 0.1
BETA: float = 50e-9
PHASE: float = 0
ACQ_DURATION = PULSE_DURATION

from qblox_scheduler import DRAGPulse, IdlePulse, ResetClockPhase, Schedule, Trace

schedule: Schedule = Schedule("trace_acquisition_tutorial")
schedule.add(IdlePulse(duration=1e-6))
schedule.add(ResetClockPhase(clock="cl0.baseband"))

schedule.add(
    DRAGPulse(
        amplitude=PULSE_AMPLITUDE,
        beta=BETA,
        duration=PULSE_DURATION,
        phase=PHASE,
        port="q0:port",
        clock="cl0.baseband",
    ),
)
schedule.add(
    Trace(duration=ACQ_DURATION, port="q0:port", clock="cl0.baseband"),
    ref_pt="start",
    rel_time=TIME_OF_FLIGHT,
)

xr_trace_acquisition: xarray.Dataset = hardware_agent.run(schedule)

Compile and run the schedule, retrieving acquisition#

As you may have already noticed, acquisitions are directly returned by the run method of the HardwareAgent. The returned data is an xarray.Dataset:

[4]:
xr_trace_acquisition
[4]:
<xarray.Dataset> Size: 24kB
Dimensions:        (trace_index_0: 1000, acq_index_0: 1)
Coordinates:
  * trace_index_0  (trace_index_0) int64 8kB 0 1 2 3 4 5 ... 995 996 997 998 999
  * acq_index_0    (acq_index_0) int64 8B 0
Data variables:
    0              (acq_index_0, trace_index_0) complex128 16kB (-0.016113281...
Attributes:
    tuid:     20261015-140824-359-4c7845

If we pay attention to the content and structure of this Dataset, we can already notice some important features:

  • The acquired data are stored in a “Data variables” mapping. For each acquisition label, there is a corresponding xarray.DataArray in this mapping. The qblox-scheduler acquisition operations (Trace in above example) have an acq_label optional argument: for each acquisition label declared, a new data variable in the resulting xarray.Dataset is created, and the corresponding xarray.DataArray of measures is attached to it. In this example, we omitted the acq_label argument, so the scheduler assigned the default label name, 0.

  • Each xarray.DataArray contains the measured data series, whose values are labeled using a coordinate system. For acquisition class Trace, a trace_index_<acq_label>coordinate axis –corresponding to the integer indexing of the acquisitions– is created. For the other acquisition classes, a similar acq_index_<acq_label> coordinate axis is always created, but other coordinates can be defined by the user, more details about it will be given later in this tutorial.

In our case, with a Trace acquisition, each trace_index_<acq_label> value represents a 1ns long measurement, which is the time resolution of the sequencers implemented on Qblox modules. The real and imaginary parts of the data correspond to the I and Q components, respectively.

xarray.Dataset embeds a wrapper to the matplotlib plotting library. We can use it to quickly visualize the acquired data:

[5]:
import matplotlib.pyplot as plt

fig, axs = plt.subplots(1)

xr_trace_acquisition[0].real.plot(ax=axs, label="I")
xr_trace_acquisition[0].imag.plot(ax=axs, label="Q")

axs.set_title("")
axs.set_ylabel("")
axs.legend()
plt.show()
../../../../_images/products_qblox_scheduler_tutorials_any_acquisitions_11_0.png

IQ integration acquisition#

The IQ integration acquisition protocol involves integrating the complex input signal over a given time period. The signal is demodulated using the specified clock before the integration happens, enabling experiments based on heterodyne detection. It’s possible to pass weights to the integration. When no weights are passed, the integration weight is a square window (this window’s length is the acquisition duration), and the allowed maximum acquisition time can be as long as 16 ms. When weights are passed for weighted integration, the length of the acquisition is limited to the length of weight memory of 16384 ns.

In this tutorial, we first start with IQ integration acquisition without weights. we will send 2 square pulses out, and measure it in 2 separate bins. In the case of IQ integration acquisition, the integration happens after demodulation.

You can use different acquisition labels to distinguish between measurements on different qubits or to separate data from successive experiments on the same qubit. In our simple example, we only use a single port-clock combination for both acquisition labels, we will then verify that those acquisition labels are reported as different data variables of a same returned xarray.Dataset.

Setting up the schedule#

[6]:
# parameters for the experiment
# --------------------------------
TIME_OF_FLIGHT: float = 148e-9
PULSE_DURATION: float = 120e-9
ACQ_DURATION = PULSE_DURATION

We define a simple helper function that sends out the square pulse with pulse_level (complex) amplitude, and then measures it after TIME_OF_FLIGHT seconds.

[7]:
from qblox_scheduler import IdlePulse, IQIntegration, Schedule, SquarePulse

schedule: Schedule = Schedule("IQIntegration_acquisition_tutorial")


def pulse_and_acquisition(pulse_level, acq_label, schedule) -> None:
    schedule.add(
        SquarePulse(
            duration=PULSE_DURATION,
            amplitude=pulse_level,
            port="q0:port",
            clock="cl0.baseband",
        ),
        ref_pt="end",
        rel_time=1e-6,  # Idle time before the pulse is played
    )
    schedule.add(
        IQIntegration(
            duration=ACQ_DURATION,
            port="q0:port",
            clock="cl0.baseband",
            acq_label=acq_label,
        ),
        ref_pt="start",
        rel_time=TIME_OF_FLIGHT,
    )


pulse_and_acquisition(pulse_level=0.5, acq_label="ch0", schedule=schedule)
pulse_and_acquisition(pulse_level=1, acq_label="ch1", schedule=schedule)

Running the schedule, retrieving acquisition#

Acquisitions are directly returned by the run method of the HardwareAgent.

[8]:
xr_iq_acquisition: xarray.Dataset = hardware_agent.run(schedule)
[9]:
import xarray as xr

print(xr_iq_acquisition["ch0"].data)
print(xr_iq_acquisition["ch1"].data)
[0.24736451+0.01390515j]
[0.51658927+0.01408004j]
[10]:
xr_iq_acquisition
[10]:
<xarray.Dataset> Size: 48B
Dimensions:        (acq_index_ch0: 1, acq_index_ch1: 1)
Coordinates:
  * acq_index_ch0  (acq_index_ch0) int64 8B 0
  * acq_index_ch1  (acq_index_ch1) int64 8B 0
Data variables:
    ch0            (acq_index_ch0) complex128 16B (0.24736450910568236+0.0139...
    ch1            (acq_index_ch1) complex128 16B (0.5165892700354259+0.01408...
Attributes:
    tuid:     20261015-140824-586-3e4a4a

There are now two xarray.DataArrays in this xarray.Dataset. These correspond to acq_label="ch0" and acq_label="ch1".

As expected, the IQ integration produced 2 complex numbers: for each acquisition label, IQIntegration integrates the successively acquired values. Notice, that the amplitude is double in the case of acq_label="ch1" compared to acq_label="ch0".

Acquisition coordinates#

General concept#

Qblox scheduler uses the concept of acquisition coordinates, so called coords, to add tags to the acquired data. It can be used to differentiate measurements even when the acq_label is the same. If no specific coords are assigned for the same acq_label (or the same coords are assigned), the measurement results will be averaged out for those acquisitions. If one wants to differentiate different measurements, different coords must be assigned, as in the example below:

[11]:
schedule: Schedule = Schedule("IQIntegration_acquisition_tutorial")


def pulse_and_acquisition(pulse_level, acq_label, schedule, coords) -> None:
    schedule.add(
        SquarePulse(
            duration=PULSE_DURATION,
            amplitude=pulse_level,
            port="q0:port",
            clock="cl0.baseband",
        ),
        ref_pt="end",
        rel_time=1e-6,  # Idle time before the pulse is played
    )
    schedule.add(
        IQIntegration(
            duration=ACQ_DURATION,
            port="q0:port",
            clock="cl0.baseband",
            acq_label=acq_label,
            coords=coords,
        ),
        ref_pt="start",
        rel_time=TIME_OF_FLIGHT,
    )


pulse_and_acquisition(
    pulse_level=0.5, acq_label="ch0", schedule=schedule, coords={"pulse_level_ch0": 0.5}
)
pulse_and_acquisition(
    pulse_level=0.5j, acq_label="ch0", schedule=schedule, coords={"pulse_level_ch0": 0.5j}
)

xr_iq_acquisition: xarray.Dataset = hardware_agent.run(schedule)
xr_iq_acquisition["ch0"].data
[11]:
array([ 0.24747839+0.0136286j , -0.02176264+0.28284522j])

Because different coords are assigned to the two measurements, we get two data points in the end. The coords key can be anything (string, number, etc.), and the values can be any kind of number (integer, real, complex, etc.). In the example above, the real and imaginary pulse amplitude result in real and imaginary acquisition data points, respectively.

It is also possible to assign same coords key to different acq_labels, demonstrated by the example below:

[12]:
schedule: Schedule = Schedule("IQIntegration_acquisition_tutorial")


def pulse_and_acquisition(pulse_level, acq_label, schedule, coords) -> None:
    schedule.add(
        SquarePulse(
            duration=PULSE_DURATION,
            amplitude=pulse_level,
            port="q0:port",
            clock="cl0.baseband",
        ),
        ref_pt="end",
        rel_time=1e-6,  # Idle time before the pulse is played
    )
    schedule.add(
        IQIntegration(
            duration=ACQ_DURATION,
            port="q0:port",
            clock="cl0.baseband",
            acq_label=acq_label,
            coords=coords,
        ),
        ref_pt="start",
        rel_time=TIME_OF_FLIGHT,
    )


pulse_and_acquisition(
    pulse_level=0.5, acq_label="ch0", schedule=schedule, coords={"pulse_level": 0.5}
)
pulse_and_acquisition(pulse_level=1, acq_label="ch1", schedule=schedule, coords={"pulse_level": 1})

xr_iq_acquisition: xarray.Dataset = hardware_agent.run(schedule)
print(xr_iq_acquisition["ch0"].data)
print(xr_iq_acquisition["ch1"].data)
xr_iq_acquisition
[0.24746212+0.01353099j        nan+0.j        ]
[       nan+0.j       0.51663401+0.013653j]
[12]:
<xarray.Dataset> Size: 96B
Dimensions:            (acq_index_ch0_ch1: 2)
Coordinates:
  * acq_index_ch0_ch1  (acq_index_ch0_ch1) int64 16B 0 1
    pulse_level        (acq_index_ch0_ch1) float64 16B 0.5 1.0
Data variables:
    ch0                (acq_index_ch0_ch1) complex128 32B (0.2474621176719665...
    ch1                (acq_index_ch0_ch1) complex128 32B (nan+0j) (0.5166340...
Attributes:
    tuid:     20261015-140824-765-938060

When the same coords key (pulse_level) is used for different acq_label (ch0 and ch1 above), xarray forces both labels onto the same dimension index by merging unique coordinates values and pad the missing spots with NaN. One common dimension name acq_index_ch1_ch0 is created. Sharing the same coords key can be useful when one is measuring multiple channels simultaneously using the same physical parameter, e.g. measuring qubit 0 and qubit 1 at the same driving frequencies.

Connecting coords with sweeps#

One important benefit of coords is to link measurement data directly to the physical parameters being swept in a schedule. When an experiment involves swept parameters (e.g. a pulse amplitude or a clock frequency), these parameters can be assigned as coordinates to the corresponding acquisitions. This provides a natural and convenient way to structure the resulting data. Such coordinates system apply to all the acquisition classes except for Trace and TimetagTrace, which have default index systems.

To illustrate this feature, let’s have a look at the following schedule, performing a two-dimension sweep over a pulse amplitude and frequency:

[13]:
# parameters for the experiment
# --------------------------------
START_FREQ: float = 80e6
STOP_FREQ: float = 82.5e6
NUM_FREQ: int = 100
START_AMP: float = -1.0
STOP_AMP: float = 1.0
NUM_AMPS: int = 10
TIME_OF_FLIGHT: float = 148e-9
[14]:
from qblox_scheduler import (
    ClockResource,
    DRAGPulse,
    DType,
    IdlePulse,
    Schedule,
    SetClockFrequency,
    linspace,
)

schedule: Schedule = Schedule(name="acquisition_with_coordinates_tutorial")
schedule.add_resource(ClockResource(name="clock", freq=0.0))

# nested loops over amplitude and frequency
with (
    schedule.loop(linspace(START_AMP, STOP_AMP, NUM_AMPS, dtype=DType.AMPLITUDE)) as amp,
    schedule.loop(linspace(START_FREQ, STOP_FREQ, NUM_FREQ, dtype=DType.FREQUENCY)) as freq,
):
    schedule.add(
        SetClockFrequency(
            clock="clock",
            frequency=freq,
        )
    )
    schedule.add(
        DRAGPulse(
            amplitude=amp,
            beta=0.5e-9,
            phase=0.0,
            duration=200.0e-9,
            port="q0:port",
            clock="clock",
        ),
    )
    schedule.add(
        IQIntegration(
            port="q0:port",
            clock="clock",
            duration=100.0e-9,
            coords={
                "frequency": freq,
                "amplitude": amp,
            },  # <-- IMPORTANT: references to loop variables
            acq_label="data",
        ),
        ref_pt="start",
        rel_time=TIME_OF_FLIGHT,
    )

xr_iq_acquisition: xarray.Dataset = hardware_agent.run(schedule)
[15]:
xr_iq_acquisition
[15]:
<xarray.Dataset> Size: 40kB
Dimensions:         (acq_index_data: 1000)
Coordinates:
  * acq_index_data  (acq_index_data) int64 8kB 0 1 2 3 4 ... 995 996 997 998 999
    amplitude       (acq_index_data) float64 8kB -1.0 -1.0 -1.0 ... 1.0 1.0 1.0
    frequency       (acq_index_data) float64 8kB 8e+07 8.003e+07 ... 8.25e+07
Data variables:
    data            (acq_index_data) complex128 16kB (-0.05742799997329712+0....
Attributes:
    tuid:     20261015-140824-874-7938e4

Executing this schedule returned a xarray.Dataset, in which we find:

  • a data variable "data" – set by the acq_label argument of IQIntegration – containing the measured data;

  • the physical coordinates frequency and amplitude.

The data structure reflects the parameters scanning performed during the schedule execution, making subsequent data analysis and visualization straightforward. Let’s have a look at how to do it.

Reshaping the data#

Looking above at how the data are formatted, one will notice that the returned data series is one dimensional (with acq_index_<acq_label> being the only dimension name). We find the coordinates amplitude and frequency in the “Coordinates” section: they also are one dimensional series, indexed by acq_index_data. Hence, acq_index_data is the unique index for all data points and additional physical coordinates of interest. It is therefore a bridge connecting those two series families.

To directly connect the acquired data points to the physical coordinates (amplitude and frequency above), we provide a helper function to reshape the data such that the dimension names will be (amplitude, frequency).

[16]:
from qblox_applications.qblox_scheduler.analysis.helpers import acq_coords_to_dims

xr_iq_acquisition: xarray.Dataset = acq_coords_to_dims(
    xr_iq_acquisition, coords=["frequency", "amplitude"]
)
xr_iq_acquisition
[16]:
<xarray.Dataset> Size: 17kB
Dimensions:    (frequency: 100, amplitude: 10)
Coordinates:
  * frequency  (frequency) float64 800B 8e+07 8.003e+07 ... 8.247e+07 8.25e+07
  * amplitude  (amplitude) float64 80B -1.0 -0.7778 -0.5556 ... 0.7778 1.0
Data variables:
    data       (frequency, amplitude) complex128 16kB (-0.05742799997329712+0...
Attributes:
    tuid:     20261015-140824-874-7938e4

This conversion being done, we can now plot the measured data in the (I, Q) plane. To do so we will use a utility plotting function whose definition can be accessed by downloading this tutorial’s code source.

[17]:
from utils.plot_utils import plot_data_iq_plane

plot_data_iq_plane(xr_iq_acquisition, groupby="amplitude")
../../../../_images/products_qblox_scheduler_tutorials_any_acquisitions_32_0.png

The amplitude of the pulse is fixing the modulus of the data points on the IQ plane. The frequency of the pulse changes the phase of the signals at QRM input ports, hence the complex axis of the data points in the IQ plane. For a fixed value of the amplitude we observe a circle in the IQ plane.

To repeat the schedule for multiple times, one can use the repetition loop explicitly within the schedule, using a code structure like

schedule = Schedule(name="test_schedule")
with schedule.loop(arange(0, 10, 1, dtype=DType.NUMBER)) as rep:
    # -----------
    # OPERATIONS
    #   ADDED
    # ----------
    schedule.add(
            IQIntegration(
                port="port",
                clock="clock",
                duration=duration,
                acq_label="my_measure",
                coords={"repetitions": rep},
            )
        )

Note that the repetition loop reference rep is kept as a coordinate of the acquisition. If no coords are assigned, the results will be averaged among the repetitions as we discussed in above sections.

Averaging during the acquisitions#

This syntax for assigning coordinates to measured data series is also compatible with the description of schedules involving averaging with respect to some axes. The rule is the following: if an acquisition is nested inside a stack of loops performing parameter sweeps, any variable that is not referenced in the coords argument of the acquisition is then averaged over. This includes the repetition loop as we saw in the previous example. It is crucial to understand, that such variable can typically be a reference to a looping operation, introduced by the python alias keyword as, in the loop context manager. This feature is therefore made possible by the qblox-scheduler grammar for Schedules involving parameter sweeps.

Let’s take again the same illustrative example: we consider a schedule that consists of generation and acquisition of a square pulse, sweeping over the amplitude value of that pulse. For each pulse amplitude, hundred repetition cycles are run, meaning that the schedule is making use of two nested loops, for amplitude and repetition.

We will prepare two versions of that schedule, with two different versions of the coords dictionary argument of the IQIntegration: one is appending all the measurements, the other is averaging over the repetitions. We set a small amplitude range on purpose to see some dispersion in the acquired values.

[18]:
# parameters for the experiment
# --------------------------------
AMP_RANGE: float = 0.01
PULSE_DURATION: float = 200e-9
TIME_OF_FLIGHT: float = 148e-9
[19]:
from qblox_scheduler import (
    ClockResource,
    DType,
    IdlePulse,
    IQIntegration,
    Schedule,
    arange,
    linspace,
)


def schedule_builder(average_rep_axis: bool = False) -> Schedule:
    schedule: Schedule = Schedule("acquisition_averaging_tutorial")
    schedule.add_resource(ClockResource(name="clock", freq=80e6))
    schedule.add(IdlePulse(duration=1e-6))

    with (
        # Definition of the two nested loops for the amplitude sweep and the repetitions
        # and the corresponding loop variables, with the `amp` and `rep` alias references
        schedule.loop(linspace(-AMP_RANGE / 2, AMP_RANGE / 2, 21, dtype=DType.AMPLITUDE)) as amp,
        schedule.loop(arange(0, 100, 1, dtype=DType.NUMBER)) as rep,
    ):
        # depending on the `average_rep_axis` mode we select, we either keep or not the `rep` variable as a
        # coordinate of the acquisition
        coords = {"amplitude": amp} if average_rep_axis else {"amplitude": amp, "rep": rep}
        schedule.add(
            SquarePulse(
                duration=PULSE_DURATION,
                amplitude=amp,
                port="q0:port",
                clock="clock",
            )
        )
        schedule.add(
            IQIntegration(
                duration=PULSE_DURATION,
                port="q0:port",
                clock="clock",
                coords=coords,
                acq_label="data",
            ),
            ref_pt="start",
            rel_time=TIME_OF_FLIGHT,
        )
    return schedule
[20]:
# create the two versions of the Schedule object
schedule_appended: Schedule = schedule_builder(average_rep_axis=False)
schedule_averaged_over_rep: Schedule = schedule_builder(average_rep_axis=True)

# execute the schedules
xr_data_appended: xarray.Dataset = hardware_agent.run(schedule_appended)
xr_data_averaged_over_rep: xarray.Dataset = hardware_agent.run(schedule_averaged_over_rep)
[21]:
from matplotlib import pyplot as plt

# let's plot the data as a function of amplitude
fig, axs = plt.subplots(1, 2, figsize=(9, 5), sharex=True)

# scatter plot of the real part
xr_data_appended["data"].real.plot.scatter(
    x="amplitude", s=15, alpha=0.15, ax=axs[0], color="tab:blue", label="Appended"
)
xr_data_averaged_over_rep["data"].real.plot.scatter(
    x="amplitude", ax=axs[0], color="tab:red", label="Averaged over rep"
)

# scatter plot of the imaginary part
xr_data_appended["data"].imag.plot.scatter(
    x="amplitude", s=15, alpha=0.15, ax=axs[1], color="tab:blue", label="Appended"
)
xr_data_averaged_over_rep["data"].imag.plot.scatter(
    x="amplitude", ax=axs[1], color="tab:red", label="Averaged over rep"
)

# labels and legends
axs[0].set_title("I quadrature")
axs[1].set_title("Q quadrature")
for ax in axs:
    ax.set_xlabel("Pulse amplitude (a.u.)")
    ax.set_ylabel("Measured signal (a.u.)")
    ax.grid(True, alpha=0.3)
    ax.legend()
plt.tight_layout()
../../../../_images/products_qblox_scheduler_tutorials_any_acquisitions_38_0.png

In blue we have the data returned by the schedule appending all the acquisitions, and in red the data coming out of the schedule performing the repetition averaging: for each amplitude value, the red point is indeed at the average position of the blue ones. Note that we added a clock resource, “clock”, of 80MHz which is also used to demodulate the input signal.

Weighted IQ integration acquisition#

Weighted IQ integration acquisition is achieved by passing weights to a regular IQ integration. In weighted IQ integration, the acquired (demodulated) data points are multiplied together with points of a weights array.

The weights can be provided in the form of two numerical arrays, weights_a for the I-path and weights_b for the Q-path of the acquisition signal, together with the acquisition duration. We emphasize again that when weights are passed for weighted integration, the length of the acquisition is limited to the length of weight memory of 16384 ns.

Important: All values in the weight arrays must be in the range [-1, 1].

As an example, we create a simple schedule using weighted IQ integration below. The setup is the same as in the IQ integration acquisition section. To show the effect of weighted integration, we will measure a square pulse three times with different weights:

  • an array with all values 1.0 (which is the same as a normal IQ integration),

  • an array with all values 0.5,

  • a sine function with amplitude 1.0.

[22]:
# parameters for the experiment
# --------------------------------
PULSE_AMPLITUDE: float = 0.1
PULSE_DURATION: float = 1e-6
TIME_OF_FLIGHT: float = 148e-9
[23]:
import numpy as np

from qblox_scheduler import IQIntegration, Schedule

schedule: Schedule = Schedule("weighted_IQacquisition_tutorial")


def add_pulse_and_weighted_acquisition_to_schedule(
    schedule,
    acq_label,
    weights_a,
    weights_b,
    duration,
):
    schedule.add(
        SquarePulse(
            duration=PULSE_DURATION,
            amplitude=PULSE_AMPLITUDE,
            port="q0:port",
            clock="cl0.baseband",
        ),
        ref_pt="end",
    )
    schedule.add(
        IQIntegration(
            port="q0:port",
            clock="cl0.baseband",
            weights_a=weights_a,
            weights_b=weights_b,
            duration=duration,
            acq_label=acq_label,
        ),
        ref_pt="start",
        rel_time=TIME_OF_FLIGHT,
    )
    return schedule


square_weights: np.ndarray = np.ones(
    1000
)  # Based on the length of the weights, and condering the default sampling rate of 1 GHz, the duration in the acquisitions will be set as 1 us.
add_pulse_and_weighted_acquisition_to_schedule(
    weights_a=square_weights,
    weights_b=square_weights,
    acq_label="ch0",
    schedule=schedule,
    duration=1e-6,
)

half_value_weights = square_weights / 2
add_pulse_and_weighted_acquisition_to_schedule(
    weights_a=half_value_weights,
    weights_b=square_weights,
    acq_label="ch1",
    schedule=schedule,
    duration=1e-6,
)

sine_weights: np.ndarray = np.sin(2 * np.pi * np.linspace(0, 1, 1000))
add_pulse_and_weighted_acquisition_to_schedule(
    weights_a=sine_weights,
    weights_b=square_weights,
    acq_label="ch2",
    schedule=schedule,
    duration=1e-6,
)

xr_data: xarray.Dataset = hardware_agent.run(schedule)

Note that the lengths of the weights arrays are all 1000. With the default sampling rate of Qblox hardware being 1 GHz, we set the acquisition duration to be 1 μs. The sampling rate is calculated by the length of the weights array and the acquisition duration:

\[\text{sampling rate} = \frac{len(\text{weights})}{\text{duration}}\]

When the calculated sampling rate is larger than 1 GHz, an error message similar to below will appear:

ValueError: Qblox hardware supports a sampling rate up to 1.0e+00 GHz, but a sampling rate of 2.0e+00 GHz was provided to WeightedAcquisitionStrategy. Please check the device configuration.

If the implied sampling rate from the weights array and the acquisition duration is smaller than 1 GHz, qblox_scheduler will interpolate based on the provided weights. The default interpolation is linear, other interpolation methods can also be used. If we continue with the acquired data set:

[24]:
print(xr_data["ch0"].data)
print(xr_data["ch1"].data)
print(xr_data["ch2"].data)
[32.66226649+13.38018227j]
[16.1809082+13.30014324j]
[0.06355333+13.50560927j]
[25]:
xr_data
[25]:
<xarray.Dataset> Size: 72B
Dimensions:        (acq_index_ch0: 1, acq_index_ch1: 1, acq_index_ch2: 1)
Coordinates:
  * acq_index_ch0  (acq_index_ch0) int64 8B 0
  * acq_index_ch1  (acq_index_ch1) int64 8B 0
  * acq_index_ch2  (acq_index_ch2) int64 8B 0
Data variables:
    ch0            (acq_index_ch0) complex128 16B (32.66226649284363+13.38018...
    ch1            (acq_index_ch1) complex128 16B (16.180908203125+13.3001432...
    ch2            (acq_index_ch2) complex128 16B (0.0635533332824707+13.5056...
Attributes:
    tuid:     20261015-140826-133-2e26fa

The data set contains three data points corresponding to the acquisitions we scheduled, respectively stored in the acquisition labels ch0, ch1 and ch2. The first acquisition with the maximum amplitude (1.0) square weights shows the highest voltage thus the highest acquired I value, the second one with the weights halved shows half the voltage and I value. The third, corresponding to the sinusoidal weights with an average of 0, shows a value close to 0 as expected.

As a final note, weighted IQ integration can also be scheduled at the gate-level by specifying "IQIntegration" as the acquisition protocol and providing the weights in the quantum device element {attr}.BasicTransmonElement.measure, for example:

<qubit>.measure.acq_weights_a = sine_weights
<qubit>.measure.acq_weights_b = square_weights

Note that BasicTransmonElement should be imported from `qblox-applications <qblox/packages/software/qblox-applications/-/tree/main?ref_type=heads>`__ other than qblox-scheduler.

Thresholded acquisition#

With thresholded acquisition, we can map a complex input signal to either a 0 or a 1, by comparing the data to a threshold value. It is similar to the IQ integration acquisition described above, but after integration, the I-Q data points are first rotated by an angle and then compared to a threshold value to assign the results to either a “0” or to a “1”. See the illustration below.

th_acq

Here the threshold line is controlled by the qubit settings: acq_rotation and acq_threshold. By default (left figure) we have acq_rotation=0 and acq_threshold=0, where every measured (integrated) data point with I<0 is assigned the state “0”, and the remaining data points are assigned the state “1”. The first setting, acq_rotation, rotates the threshold line by an angle in degrees (0 - 360), clockwise. The second setting, acq_threshold, sets the threshold that is compared to the rotated integrated acquisition result.

Note: The qblox-instruments parameter thresholded_acq_threshold corresponds to a voltage obtained from an integrated acquisition, before normalizing with respect to the integration time. The qblox-scheduler parameter acq_threshold corresponds to an acquired voltage after normalizing with respect to the integration time (e.g. as obtained from a regular IQ integration acquisition).

Setting up the schedule#

Let’s consider a simple example where one acquires a pulse, whose amplitude is swept over. In the IQ plane, the corresponding data points are distributed along a line crossing the origin. Let’s use the threshold acquisition such that –after rotation in the IQ plane– points with positive real part are counted as being in the \(|1\rangle\) state, otherwise \(|0\rangle\) state. This can for example be applied for state measurement of a transmon.

Let’s first proceed to raw measurement of the points, and plot it in the IQ plane.

[26]:
# parameters for the experiment
# --------------------------------
PULSE_DURATION: float = 1e-6
ACQ_DURATION: float = 0.8 * PULSE_DURATION
TIME_OF_FLIGHT: float = 148e-9
[27]:
from qblox_scheduler import ClockResource, DType, IQIntegration, Schedule, linspace

schedule: Schedule = Schedule("IQ_acquisition")
schedule.add_resource(ClockResource(name="clock", freq=80e6))

with schedule.loop(linspace(-1.0, 1.0, 6, dtype=DType.AMPLITUDE)) as amp:
    schedule.add(
        SquarePulse(
            duration=PULSE_DURATION,
            amplitude=amp,
            port="q0:port",
            clock="clock",
        ),
    )
    schedule.add(
        IQIntegration(
            duration=ACQ_DURATION,
            port="q0:port",
            clock="clock",
            acq_label="data",
            coords={"amp": amp},
        ),
        ref_pt="start",
        rel_time=TIME_OF_FLIGHT,
    )

Then we fit the corresponding line on which these points are aligned in the IQ plane.

[28]:
import numpy as np
from numpy.polynomial.polynomial import Polynomial

if cluster.is_dummy:
    xr_raw_data = xr.open_dataset("./utils/data/data_thresholded_raw.hdf5", engine="h5netcdf")
else:
    xr_raw_data = hardware_agent.run(schedule)

# linear fit of the acquired data: y = mx + p
p, m = Polynomial.fit(x=xr_raw_data.data.real, y=xr_raw_data.data.imag, deg=1).convert().coef

# angle of the threshold line (orthogonal to the linear fit)
angle_cutoff: float = np.atan(m) + np.pi / 2

# slope of the orthogonal line (threshold line)
m_cutoff: float = np.tan(angle_cutoff)

print(f"Angle of the threshold line with the I axis: {np.rad2deg(angle_cutoff):.2f}°")
Angle of the threshold line with the I axis: 21.22°

And let’s plot it in a graph:

[29]:
from matplotlib import pyplot as plt

# making the plot
fig, ax = plt.subplots()

# plotting parameters
ax_range = 0.5
ymin, ymax = -ax_range, ax_range
x_fill = np.linspace(-ax_range, ax_range, 200)


ax.set_xlim([-ax_range, ax_range])
ax.set_ylim([-ax_range, ax_range])
ax.set_aspect("equal", adjustable="box")
ax.grid()
ax.set_xlabel("I (a.u.)")
ax.set_ylabel("Q (a.u.)")
ax.set_title("Acquired data plotted in the IQ plane")

# plotting the fitted line of data points
plt.axline((0, p), slope=m, linestyle="--")

# plotting the threshold line
plt.axline((0, p), slope=m_cutoff, linestyle="--", color="k")


# --- Shading the areas ✨ ---
# 1. Calculate the y-values of the cutoff line for the x-values
y_fill_line = m_cutoff * x_fill + p

# 2. Shade the area ABOVE the cutoff line
ax.fill_between(x_fill, y_fill_line, ymax, color="lightblue", alpha=0.5, label="Above Cutoff")

# 3. Shade the area BELOW the cutoff line
ax.fill_between(x_fill, ymin, y_fill_line, color="lightcoral", alpha=0.5, label="Below Cutoff")

# 4. Label the areas
ax.text(0.1, 0.3, '"$|0\\rangle$" state', fontsize=16)
ax.text(0.2, -0.2, '"$|1\\rangle$" state', fontsize=16)
# -----------------

# plotting the data
ax.scatter(x=xr_raw_data.data.real, y=xr_raw_data.data.imag)
[29]:
<matplotlib.collections.PathCollection at 0x7f3a90a574d0>
findfont: Failed to find font weight 300 for STIXGeneral, now using 400.
findfont: Failed to find font weight 300 for STIXGeneral, now using 400.
findfont: Failed to find font weight 300 for STIXNonUnicode, now using 400.
findfont: Failed to find font weight 300 for STIXNonUnicode, now using 400.
findfont: Failed to find font weight 300 for STIXSizeOneSym, now using 400.
findfont: Failed to find font weight 300 for STIXSizeTwoSym, now using 400.
findfont: Failed to find font weight 300 for STIXSizeThreeSym, now using 400.
findfont: Failed to find font weight 300 for STIXSizeFourSym, now using 400.
findfont: Failed to find font weight 300 for STIXSizeFiveSym, now using 400.
findfont: Failed to find font weight 300 for cmmi10, now using 400.
findfont: Failed to find font weight 300 for cmsy10, now using 400.
findfont: Failed to find font weight 300 for cmr10, now using 400.
findfont: Failed to find font weight 300 for cmtt10, now using 400.
findfont: Failed to find font weight 300 for cmti10, now using 400.
findfont: Failed to find font weight 300 for cmb10, now using 400.
findfont: Failed to find font weight 300 for cmss10, now using 400.
findfont: Failed to find font weight 300 for cmex10, now using 400.
findfont: Failed to find font weight 300 for DejaVu Sans, now using 400.
findfont: Failed to find font weight 300 for DejaVu Sans, now using 400.
findfont: Failed to find font weight 300 for DejaVu Sans Mono, now using 400.
findfont: Failed to find font weight 300 for DejaVu Sans Display, now using 400.
../../../../_images/products_qblox_scheduler_tutorials_any_acquisitions_54_2.png

Thresholded acquisition using ‘ThresholdedIQIntegration’#

We proceed next to the thresholded acquisition. Since we want to separate positive and negative real parts, we set the threshold to 0. With the previous fit of the raw data, we also already computed the value angle_cutoff from which the data should be rotated before comparison.

[30]:
from qblox_scheduler import (
    ClockResource,
    DType,
    Schedule,
    SquarePulse,
    ThresholdedIQIntegration,
    linspace,
)

THRESHOLD: float = 0.0

thres_acq_sched: Schedule = Schedule("thresholded_acquisition")
thres_acq_sched.add_resource(ClockResource(name="clock", freq=80e6))

with thres_acq_sched.loop(linspace(-1.0, 1.0, 6, dtype=DType.AMPLITUDE)) as amp:
    thres_acq_sched.add(
        SquarePulse(
            duration=PULSE_DURATION,
            amplitude=amp,
            port="q0:port",
            clock="clock",
        ),
    )
    thres_acq_sched.add(
        ThresholdedIQIntegration(
            duration=ACQ_DURATION,
            port="q0:port",
            clock="clock",
            acq_label="data",
            acq_rotation=angle_cutoff,
            acq_threshold=THRESHOLD,
            coords={"amp": amp},
        ),
        ref_pt="start",
        rel_time=TIME_OF_FLIGHT,
    )
[31]:
if cluster.is_dummy:
    xr_data_thresholded = xr.open_dataset("./utils/data/data_thresholded.hdf5", engine="h5netcdf")
    xr_data_thresholded.coords["amp"].values
    xr_data_thresholded.data.data
else:
    xr_data_thresholded: xarray.Dataset = hardware_agent.run(thres_acq_sched)

As expected, the 3 first points are identified as being in the 0 state (I values below threshold), and the 3 last points are indeed in the 1 state (above threshold).

Trigger count acquisition#

The trigger count acquisition protocol is used for measuring how many times the input signal goes over some limit. This protocol is used, for example, in cases where one wants to count the number of detection events in an experiment.

The trigger count protocol is currently only available on two module types: the QRM (baseband) and the QTM.

Similarly to what was discussed above with the IQ integration acquisition, we can make use of the coords argument to append or average the number of trigger events received during a given acquisition window. If a loop reference is set as being a coordinate of the acquisition, the number of counts is appended at each iteration of the loop.

In addition, there is the TriggerCountDistribution protocol which offers the distribution mode. It can only be used with the QRM.

In the TriggerCountDistribution protocol, the result is a distribution that maps the trigger count numbers to the number of occurrences of each trigger count number. This provides insights into the overall occurrence of triggers when running the acquisition multiple times. For example, imagine an experiment where a TriggerCount acquisition runs three times and detects [1, 3, 1] triggers respectively. In distribution mode, the instrument directly compiles a histogram of these results. It would report that a count of ‘1’ occurred twice and a count of ‘3’ occurred once, returning a result like {1: 2, 3: 1}. The dictionary notation shows the number of triggers as keys and their corresponding frequencies as values. Moreover, in TriggerCountDistribution there is no need to store and transfer the result of each individual acquisition and the distribution is handled on the Qblox hardware, TriggerCountDistribution is much faster than TriggerCount for the same amount of acquisitions.

For the input signal to be registered as counts, we need to set an acquisition threshold. The threshold is set for the QRM via the ttl_acq_threshold in the TTL acquisition threshold field, while for the QTM this threshold is set via the field analog_threshold in the digitization threshold hardware option.

Setting up the schedule#

Let’s have an illustrative example showing how this type of acquisition works in practice.

We use a source of random dirac pulses that we made up with a function generator: it plays a 20 ns wide TTL pulse in burst mode, externally triggered with a source of noise. The ratio between the burst trigger level of the function generator, and the RMS amplitude of the noise sets the average flux of pulses emitted by the source. In practice we set it up to have a flux of a few pulses per micro-second.

This source of random pulses is then connected to the second input of a QRM module, to proceed to the detection and counting.

Let’s first set up the hardware configuration for this experiment. We will set up the counting threshold of the QRM to 60% of the maximum input amplitude (so 0.6 x 0.5 V = 300 mV).

We will perform the acquisitions in a window of 15 us:

[32]:
ACQ_DURATION: float = 15e-6
[33]:
hardware_cfg_trigger_count: dict[str, Any] = {
    "config_type": "QbloxHardwareCompilationConfig",
    "version": "0.2",
    "hardware_description": {
        "cluster0": {
            "instrument_type": "Cluster",
            "modules": {
                4: {"instrument_type": "QRM"},
            },
            "ref": "internal",
        },
    },
    "hardware_options": {
        "sequencer_options": {
            "qrm:in1-digital": {
                "ttl_acq_threshold": 0.6,
            }
        },
    },
    "connectivity": {
        "graph": [
            ["cluster0.module4.complex_output_0", "qrm:out"],
            ["cluster0.module4.real_input_0", "qrm:in0"],
            ["cluster0.module4.real_input_1", "qrm:in1"],
        ]
    },
}

hardware_agent: HardwareAgent = HardwareAgent(hardware_configuration=hardware_cfg_trigger_count)
hardware_agent.connect_clusters()
/.venv/lib/python3.14/site-packages/qblox_scheduler/qblox/hardware_agent.py:603: UserWarning: cluster0: Trying to instantiate cluster with ip 'None'.Creating a dummy cluster.
  warnings.warn(

First check: scope acquisition#

We will start by simply performing a scope acquisition of the signal, to verify how it looks.

[34]:
from qblox_scheduler import Schedule, Trace

schedule: Schedule = Schedule("trace_acquisition_tutorial")

schedule.add(Trace(duration=ACQ_DURATION, acq_label="scope", port="qrm:in1", clock="cl0.baseband"))

xr_ttl_trace_acquisition: xarray.Dataset = hardware_agent.run(schedule)
Invalid input indices are connected. Scope data might be invalid. Connected input indices are (1,). Valid indices are (0, 1) and (2, 3).
[35]:
import xarray as xr

xr_ttl_trace_acquisition = xr.open_dataset(
    "./utils/data/trigger_count_scope.hdf5", engine="h5netcdf"
)
[36]:
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(10, 4))

xr_ttl_trace_acquisition["scope"].imag.plot(ax=ax)

ax.set_xlabel("Time (ns)")
ax.set_ylabel("Amplitude (a.u.)")
ax.set_title("Acquired trace")
ax.set_ylim(-0.1, 1.0)
ax.grid()
../../../../_images/products_qblox_scheduler_tutorials_any_acquisitions_66_0.png

Appending the counts#

We will repeat a TriggerCount acquisition, appending all the result until we entirely filled the acquisition memory of a QRM (currently set to a maximum of 3000000 bins).

[37]:
from qblox_scheduler import DType, Schedule, TriggerCount

REPS: int = 3_000_000  # maximum number of bins usable on QRM memory

schedule: Schedule = Schedule("trigger_count_acquisition_tutorial")

with schedule.loop(arange(0, REPS, 1, dtype=DType.NUMBER)) as rep:
    schedule.add(
        TriggerCount(
            duration=ACQ_DURATION,
            port="qrm:in1",
            clock="cl0.baseband",
            acq_label="counts",
            coords={"rep": rep},
        )
    )
xr_ttl_append_counts_acquisition: xarray.Dataset = hardware_agent.run(schedule)
[38]:
import xarray as xr

xr_ttl_append_counts_acquisition = xr.open_dataset(
    "./utils/data/simulated_3M_trigger_count_append.hdf5", engine="h5netcdf"
)

Let’s now plot the histogram of the 3 million appended counts, as well as the gaussian that we can extract from the computation of the mean value and standard deviation of this data series.

[39]:
fig, ax = plt.subplots()

avg = xr_ttl_append_counts_acquisition["counts"].mean().item()
std = xr_ttl_append_counts_acquisition["counts"].std().item()

x = np.linspace(0, 40, 100)
y = np.exp(-0.5 * ((x - avg) / std) ** 2) * REPS / (std * np.sqrt(2 * np.pi))

xr_ttl_append_counts_acquisition["counts"].plot.hist(
    bins=list(range(41)), edgecolor="black", align="left", ax=ax, label="Appended data histogram"
)
ax.plot(x, y, color="tab:red", ls="dashed", lw=2, label="Gaussian fit")


ax.legend()
ax.set_xlabel("Counts")
ax.set_ylabel("Occurrences")
ax.set_title(f"Histogram of Trigger Counts over {REPS} repetitions")
ax.text(30, 6000, f"Mean: {avg:.2f}\nStd: {std:.2f}")
[39]:
Text(30, 6000, 'Mean: 19.43\nStd: 3.72')
../../../../_images/products_qblox_scheduler_tutorials_any_acquisitions_71_1.png

We therefore measured a flux of \(1.3 \pm 0.2\) pulse per micro-second.

Distribution mode#

From what was explained before, it is clear that above histogram could have been obtained in one step (i.e. without data analysis function) using the TriggerCountDistribution class.

Not only does it spare us from doing any post-analysis of the raw data (the histogram binning is happening on the instrument side), but also – and more importantly – there will be more detection events allowed since each bin can contain the information of more than one detection event, meaning that the acquisition can be iterated much longer: let’s do it for 10 million iterations.

[40]:
from qblox_scheduler import DType, Schedule, TriggerCountDistribution

REPS: int = 10_000_000  # maximum number of bins usable on instrument memory

schedule: Schedule = Schedule("trigger_count_acquisition_tutorial")

with schedule.loop(arange(0, REPS, 1, dtype=DType.NUMBER)) as rep:
    schedule.add(
        TriggerCountDistribution(
            duration=ACQ_DURATION,
            port="qrm:in1",
            clock="cl0.baseband",
            acq_label="occurrence",
        )
    )
xr_data_distr: xarray.Dataset = hardware_agent.run(schedule)
[41]:
import xarray as xr

xr_data_distr = xr.open_dataset(
    "./utils/data/simulated_10M_trigger_count_distr.hdf5", engine="h5netcdf"
)
[42]:
fig, ax = plt.subplots()

avg = np.average(xr_data_distr["counts_occurrence"], weights=xr_data_distr["occurrence"]).item()
std = np.sqrt(
    np.average((xr_data_distr["counts_occurrence"] - avg) ** 2, weights=xr_data_distr["occurrence"])
).item()

x = np.linspace(0, 40, 100)
y = np.exp(-0.5 * ((x - avg) / std) ** 2) * REPS / (std * np.sqrt(2 * np.pi))

ax.plot(x, y, color="tab:red", ls="dashed", lw=2, label="Gaussian fit")
xr_data_distr["occurrence"].plot(
    x="counts_occurrence",
    marker="o",
    ls="None",
    color="tab:green",
    ax=ax,
    label="Distribution data points",
)


ax.legend()
ax.set_xlabel("Counts")
ax.set_ylabel("Occurrences")
ax.set_title(f"Histogram of Trigger Counts over {REPS} repetitions")
ax.text(30, 40000, f"Mean: {avg:.2f}\nStd: {std:.2f}")
[42]:
Text(30, 40000, 'Mean: 19.46\nStd: 3.71')
../../../../_images/products_qblox_scheduler_tutorials_any_acquisitions_76_1.png

Thresholded trigger count acquisition#

Generalities#

The thresholded trigger count protocol combines the functionality of the trigger count acquisition with a final comparison step. It first counts the rising edges of an input signal over a duration and then compares this total count to a specified integer threshold. The threshold comparison result (False (0), or True (1)) can be retrieved directly, and can also be used to conditionally execute other instructions. For the latter, you would use the ConditionalOperation.

We propose here to keep using our source of random pulses for illustrating this acquisition protocol. We still use 15 us long acquisition windows, within which the pulses are counted. As shown in the figure below, if the number of counts is smaller than the threshold, we trigger an event on the cluster internal network, and we count the acquisition as a 1, otherwise no event is triggered and we count it as a 0.

alt

By scanning over the value \(n_{th}\) of the threshold, the probability \(P_1(n_{th})\) of having the acquisition returning 1 should follow the erf error function, corresponding the antiderivative of the gaussian.

Schedule and conditional operation#

The source of pulses is connected to the input 1 of the QRM. An oscilloscope is connected to the output channels of the QRM to check if the conditionally triggered operation are correctly executed. We start by writing a simple schedule:

  1. a 1 us long square pulse is played by the QRM: it is used to trigger a single acquisition with the oscilloscope;

  2. we wait 1 us;

  3. the acquisition starts, it lasts 15 us and an event is send on the trigger network (label “click”) if at least 1 count has been detected: regarding the typical number of pulses detected in 15 us (see above), this effectively ensures the emission of an event;

  4. a negative square pulse is played, triggered by the event on the “click” channel;

[43]:
from qblox_scheduler import (
    ConditionalOperation,
    IdlePulse,
    Schedule,
    SquarePulse,
    ThresholdedTriggerCount,
)

schedule: Schedule = Schedule("Conditional operation")
schedule.add(SquarePulse(amplitude=1, duration=1e-6, port="qrm:out", clock="cl0.baseband"))
schedule.add(IdlePulse(duration=1e-6))
schedule.add(
    ThresholdedTriggerCount(
        port="qrm:in1",
        clock="cl0.baseband",
        duration=15e-6,
        threshold=1,  # very small threshold to ensure a trigger
        feedback_trigger_label="click",  # label of the trigger to be used in the ConditionalOperation
    )
)
schedule.add(
    ConditionalOperation(
        body=SquarePulse(amplitude=-1, duration=1e-6, port="qrm:out", clock="cl0.baseband"),
        feedback_trigger_label="click",
    ),
)
schedule.add(IdlePulse(4e-9))
[43]:
{'name': 'c65f6423-07ff-4af0-ad3c-087a1d657319', 'operation_id': '-5541197746935506874', 'timing_constraints': [TimingConstraint(ref_schedulable=None, ref_pt=None, ref_pt_new=None, rel_time=0)], 'label': 'c65f6423-07ff-4af0-ad3c-087a1d657319'}
[44]:
import numpy as np
from matplotlib import pyplot as plt

scope_data = np.genfromtxt(
    "./utils/data/conditional_playback_wf.csv",
    delimiter=",",
    skip_header=12,
)


fig, ax = plt.subplots(figsize=(12, 4))

arr_time, arr_trace = scope_data[:, 0], scope_data[:, 1]

ax.axvspan(1, 2, color="pink", alpha=0.5)
ax.axvspan(2, 17, color="tab:green", alpha=0.2)
ax.text(9.5, 0.25, "Acquisition", fontsize=12, ha="center", color="green")
ax.plot(arr_time * 1e6, arr_trace)

ax.set_xlabel("Time (us)")
ax.set_ylabel("Amplitude (V)")
ax.set_title("Acquired trace after conditional operation triggered")
ax.grid()

plt.tight_layout()
plt.savefig("./utils/images/conditional_playback_trace.png", dpi=300)
plt.close(fig)

Let’s plot the trace acquired by the scope is indeed showing two pulses of opposite polarities, and check that the duration between both pulses is indeed the expected one:

cond_playback

[45]:
threshold: float = 0.5
above_threshold: np.ndarray = arr_trace > threshold
below_threshold: np.ndarray = arr_trace < -threshold
first_pulse_timestamp: int = np.where(above_threshold)[0][0]
second_pulse_timestamp: int = np.where(below_threshold)[0][0]
delta_t: int = arr_time[second_pulse_timestamp] - arr_time[first_pulse_timestamp]
print(f"Delay time between pulses: {delta_t * 1e6:.1f} us")
Delay time between pulses: 17.0 us

Let’s now proceed to complete the experiment, where the threshold value is scanned, and for each value of the threshold we perform 100_000 iteration of the ThresholdedTriggerCount acquisition.

[46]:
from qblox_scheduler import (
    ConditionalOperation,
    IdlePulse,
    Schedule,
    SquarePulse,
    ThresholdedTriggerCount,
    TriggerCondition,
)

datasets: list[xarray.Dataset] = []
for threshold in range(40):
    schedule: Schedule = Schedule("Conditional operation")

    schedule.add(SquarePulse(amplitude=1, duration=1e-6, port="qrm:out", clock="cl0.baseband"))
    schedule.add(IdlePulse(duration=1e-6))

    with schedule.loop(arange(0, 100_000, 1, dtype=DType.NUMBER)) as rep:
        schedule.add(
            ThresholdedTriggerCount(
                port="qrm:in1",
                clock="cl0.baseband",
                duration=15e-6,
                threshold=threshold,
                feedback_trigger_label="click",
                acq_label="thresholded_count",
                feedback_trigger_condition=TriggerCondition.LESS_THAN,
            )
        )
        schedule.add(
            ConditionalOperation(
                body=SquarePulse(amplitude=-1, duration=1e-6, port="qrm:out", clock="cl0.baseband"),
                feedback_trigger_label="click",
            ),
        )
        schedule.add(IdlePulse(4e-9))

    datasets.append(hardware_agent.run(schedule))
[47]:
import pickle as pkl
from pathlib import Path

source_file = Path("./utils/data/datasets.pkl")
with source_file.open("rb") as f:
    datasets = pkl.load(f)
[48]:
cumulative_probability: list[float] = []
for dataset in datasets:
    cumulative_probability.append(dataset.thresholded_count.sum().item() / 100_000)

fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(range(40), cumulative_probability, marker="o")
ax.set_xlabel("Threshold")
ax.set_ylabel("Probability")
ax.set_title("Cumulative probability to detect a trigger vs threshold")
ax.grid()
../../../../_images/products_qblox_scheduler_tutorials_any_acquisitions_86_0.png