See also

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

Scope Mode#

The Qblox Timetagging Module (henceforth referred to as the QTM) allows recording of absolute timetags.

It is highly recommended to go through the Timetag Sequencer page before going through this tutorial.

This tutorial covers the Scope Mode of operation for the QTM as described in the timetag sequencer page. This tutorial will demonstrate how to use each of the three major sub-modes effectively in a Q1ASM sequencer program.

  • Continuous Scope acquisition

  • Windowed Scope acquisition

  • Low-Latency Burst

[1]:
from __future__ import annotations

import pickle as pkl

import matplotlib.pyplot as plt
import numpy as np
from qcodes.instrument import find_or_create_instrument

from qblox_instruments import Cluster, ClusterType

Scan For Clusters#

We scan for the available devices connected via ethernet using the Plug & Play functionality of the Qblox Instruments package (see Plug & Play for more info).

!qblox-pnp list

[2]:
cluster_ip = "10.10.200.42"
cluster_name = "cluster0"

Connect to Cluster#

We now make a connection with the Cluster.

[3]:
cluster: Cluster = find_or_create_instrument(
    Cluster,
    recreate=True,
    name=cluster_name,
    identifier=cluster_ip,
    dummy_cfg=(
        {
            2: ClusterType.CLUSTER_QCM,
            4: ClusterType.CLUSTER_QRM,
            6: ClusterType.CLUSTER_QCM_RF,
            8: ClusterType.CLUSTER_QRM_RF,
            10: ClusterType.CLUSTER_QTM,
            12: ClusterType.CLUSTER_QRC,
            16: ClusterType.CLUSTER_QSM,
        }
        if cluster_ip is None
        else None
    ),
)

cluster.reset()
print(cluster.get_system_status())
Status: ERROR, Flags: FEEDBACK_NETWORK_CALIBRATION_FAILED, Slot flags: NONE

Get connected modules#

[4]:
# QTM modules
modules = cluster.get_connected_modules(lambda mod: mod.is_qtm_type)
# This uses the module of the correct type with the lowest slot index
module = list(modules.values())[0]

Setup#

To demonstrate Scope Mode and the QTM’s timetagging capability, we will send a sequence of digital pulses from one output channel (channel 1) and detect them on another input channel (channel 5).

The pulse sequence consists of:

  • 20 ns-wide TTL pulses

  • spaced with increasing delays, where the delay between each pulse increases linearly by 1 \(\mu s\) relative to the previous one. The first pulse starts 80 ns after the beginning of the acquisition window.

To capture these pulses, we will:

  • open an acquisition window on the input channel

  • enable Scope Mode to acquire the timetags

The goal is to observe the detected TTL pulses and verify that their timetags correspond to the expected timing defined by the pulse sequence.

[5]:
# Define the pulse duration for our output loop and the initial wait time.
PULSE_DURATION: int = 20  # ns
INITIAL_WAIT_TIME: int = 80  # ns
INCREASING_WAIT_TIME: int = 1000  # ns

# Set the pulse number to the total number of timetags we wish to acquire.
PULSE_NUMBER: int = 20
# The threshold voltage that that a signal need to pass in order to be classed a detection event.
# NOTE: The minimum set value of the analog threshold for successful detection of counts and timetags is 400 mV (corresponding to a THRESHOLD value of 0.4).
# Input signals at lower voltage than 400mV will not register as counts (or timetags) even if the signal value is higher than the set analog threshold.
THRESHOLD: float = 0.5

Configure QTM#

Firstly, we need to configure the QTM’s channels. The QTM provides a one-to-one mapping between the inputs and outputs of its eight sequencers - configuring a channel means configuring the sequencer with the same index.

Note that each QTM channel can operate as either an input or an output. Their indexing in the ``qblox_instruments`` API goes from ``Module.io_channel0`` to ``Module.io_channel7``, whereas the index label on the front panel of the module (this is what is referred to in the text above and throughout the script) goes from 1 to 8.

In this demonstration:

  • We will use channel 1 (front panel label) as an output, to send digital TTL pulses.

  • These pulses will be detected and acquired by channel 5 (also front panel label), configured as an input.

  • Note that a physical connection via a SMP cable is required for this tutorial between channel 1 and channel 5.

[6]:
# Synchronize the two sequencers that will be used.
module.sequencer0.sync_en(True)
module.sequencer4.sync_en(True)

# Set IO Switch of channel 1 to output
module.io_channel0.mode("output")

# Set IO Switch of channel 4 to input, and set the threshold for detection.
module.io_channel4.mode("input")
module.io_channel4.analog_threshold(THRESHOLD)

# Set the sequencer to trigger the scope mode.
module.io_channel4.scope_trigger_mode("sequencer")

Define a program for the output of digital pulses.

[7]:
program_pulse = f"""
    move            {INITIAL_WAIT_TIME - 4}, R0
    move            {PULSE_NUMBER}, R1
    wait_sync       4

    loop:
        set_digital 1,1,0  # level (1=high), mask (always 1 for v1), fine_delay in steps of 1/128 ns
        upd_param   4
        wait        {PULSE_DURATION - 4}
        set_digital 0,1,0
        upd_param   4
        wait        R0

        add         R0,{INCREASING_WAIT_TIME}, R0
        nop
        loop        R1, @loop
    stop
"""

Scope sub-mode : Continuous (timetags)#

[8]:
# Set the scope mode to acquire all timetags.
module.io_channel4.scope_mode("timetags")

Define a program for the acquisition of timetags. For more details about the acquire_timetags instruction, please refer to the Q1ASM User Guide.

[9]:
program_acq = """
    wait_sync   4

    set_scope_en 1                  # Enable the scope acquisition

    # Opening the acquisition window, triggering the  start of scope acquisition.
    acquire_timetags 0, 0, 1, 0, 4  # acquisition index, bin index, window open(1)/close(0), fine_delay in steps of 1/128 ns, duration

    # Closing the acquisition window, note that in this mode scope acquisition will not cease.
    acquire_timetags 0, 0, 0, 0, 4  # Arguments in order are: acquisition index, bin index, window open(1)/close(0), fine_delay, duration

    stop
"""

Create an acquisition dictionary to store timetags and upload the programs to the appropriate sequencer.

[10]:
# Define acquisition dictionary.
acquisition_modes: dict[str, dict[str, int]] = {"timetag_single": {"num_bins": 1, "index": 0}}
# Upload the pulse program to the sequencer.
module.sequencer0.sequence(
    {
        "waveforms": {},
        "weights": {},
        "acquisitions": {},
        "program": program_pulse,
    }
)
# Upload the acquisitions and program to the sequencer.
module.sequencer4.sequence(
    {
        "waveforms": {},
        "weights": {},
        "acquisitions": acquisition_modes,
        "program": program_acq,
    }
)

module.sequencer0.arm_sequencer()
module.sequencer4.arm_sequencer()
[11]:
# Start sequencer.
module.start_sequencer()
[12]:
# Check sequencer status.
print(module.get_sequencer_status(0))
print(module.get_sequencer_status(4))

if cluster.is_dummy:
    with open("dependencies/130_timetagging_scope_acq_timetags_data.pkl", "rb") as f:
        # dummy data if the dummy cluster is used
        scope_data = pkl.load(f)
else:
    scope_data = module.io_channel4.get_scope_data()
Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: NONE, Warning Flags: NONE, Error Flags: NONE, Log: []
Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: ACQ_BINNING_DONE, Warning Flags: NONE, Error Flags: NONE, Log: []

The absolute timetags are expressed in units of (1/2048) ns relative to the CTC (internal clock) of the module. This means we must convert the acquired timetags to the appropriate units relative to the timetag of the first event in the trace.

[13]:
print(f"Number of acquired timetags in window: {len(scope_data)}")

# Convert the acquired timetags to the correct units.
# Here, we subtract from the first received timetag to get the relative times of arrival.
timetags = np.array([(i[1] - scope_data[0][1]) / (2048e3) for i in scope_data])

fig, ax = plt.subplots(1, 1)
ax.plot(timetags, np.ones(len(timetags)), "o", label="Timetags relative to first detection")
ax.set_xlabel(r"Time ($\mu$s)")
ax.set_ylabel("Photon detection")
plt.legend(loc="upper right")
plt.show()
Number of acquired timetags in window: 20
../../../../_images/products_qblox_instruments_tutorials_QTM_130_scope_mode_qtm_26_1.png

Scope sub-mode : Windowed(timetags-windowed)#

We now repeat the scope acquisition from the previous section, but this time using windowed scope mode. In this mode, timetags are only recorded during explicitly defined time windows.

The output continues to send the same sequence of pulses as before. However, by carefully selecting the acquisition window in the Q1ASM program, we intentionally skip one of the pulses — demonstrating how windowing can be used to filter out specific events.

[14]:
# Set the scope mode to acquire timetags in a certain window for the acquisition channel.
module.io_channel4.scope_mode("timetags-windowed")

# Set the acquisition duration.
ACQUISITION_DURATION: int = 6000
[15]:
# The duration of each acquisition window is determined by how long the `acquire_timetags_window` instruction
# keeps the window open in the Q1ASM sequence.
#
# In this example, we define a Q1ASM program that opens two acquisition windows, each lasting 6 microseconds,
# separated by a 4.2 microsecond delay (`wait` instruction).

program_acq = f"""
    wait_sync   4

    acquire_timetags 0, 0, 1, 0, 4 # Open first window
    wait             {ACQUISITION_DURATION - 4}
    acquire_timetags 0, 0, 0, 0, 4 # Close first window
    wait             4200
    acquire_timetags 0, 0, 1, 0, 4 # Opening second window
    wait             {ACQUISITION_DURATION - 4}
    acquire_timetags 0, 0, 0, 0, 4 # Closing the second window
    stop
"""
[16]:
# Upload the pulse program to the sequencer.
module.sequencer0.sequence(
    {
        "waveforms": {},
        "weights": {},
        "acquisitions": {},
        "program": program_pulse,
    }
)

# Upload the acquisitions program to the sequencer.
module.sequencer4.sequence(
    {
        "waveforms": {},
        "weights": {},
        "acquisitions": acquisition_modes,
        "program": program_acq,
    }
)
[17]:
# Arm sequencers.
module.sequencer0.arm_sequencer()
module.sequencer4.arm_sequencer()
[18]:
# Start sequencers.
module.start_sequencer()
[19]:
# Check sequencer status.
print(module.get_sequencer_status(0))
print(module.get_sequencer_status(4))

# Wait for the acquisition to finish with a timeout period of one minute.
module.get_acquisition_status(4, 1)
# Extract scope data.
if cluster.is_dummy:
    with open("dependencies/130_timetagging_scope_acq_timetags_windowed_data.pkl", "rb") as f:
        # dummy data if the dummy cluster is used
        scope_data_window = pkl.load(f)
else:
    scope_data_window = module.io_channel4.get_scope_data()
Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: FORCED_STOP, Warning Flags: NONE, Error Flags: NONE, Log: []
Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: ACQ_BINNING_DONE, FORCED_STOP, Warning Flags: NONE, Error Flags: NONE, Log: []
[20]:
# The plot shows the TTL events sent by the QTM (zoomed in previous graph) and the data received by the windowed acquisition.
# The pulse at 6.3 microseconds is ignored by the QTM since it arrives outside of the acquisition windows.

print(f"Number of acquired timetags in window: {len(scope_data_window)}")

# Convert the units of timetags to us.
timetags_window = np.array(
    [(i[1] - scope_data_window[0][1]) / (2048e3) for i in scope_data_window]
)  # in us

fig, ax = plt.subplots(2, 1, figsize=(10, 6))

ax[0].plot(timetags[:7], np.ones(len(timetags[:7])), "o", label="Previous recorded timetags")
ax[0].set_xlabel(r"Time ($\mu$s)")
ax[0].set_ylabel("Photon detection")
ax[0].minorticks_on()
# Major grid (default style)
ax[0].grid(which="major", linestyle="-", axis="x")
# Minor grid (customized style)
ax[0].grid(which="minor", linestyle=":", axis="x")


c_open = 0
c_close = 0

for i in range(len(timetags_window)):
    if scope_data_window[i][0] == "OPEN":
        c_open += 1
        ax[1].axvline(timetags_window[i], color="g", label=f"Window {c_open} open")

    elif scope_data_window[i][0] == "CLOSE":
        c_close += 1
        ax[1].axvline(timetags_window[i], color="r", label=f"Window {c_close} close")

    elif scope_data_window[i][0] == "RISE":
        ax[1].plot(timetags_window[i], 1.0, marker="o", color="blue")
ax[1].set_xlabel(r"Time ($\mu$s)")
ax[1].set_ylabel("Photon detection")
ax[1].minorticks_on()
# Major grid (default style)
ax[1].grid(which="major", linestyle="-", axis="x")
# Minor grid (customized style)
ax[1].grid(which="minor", linestyle=":", axis="x")

plt.legend(loc="upper right")

plt.show()
Number of acquired timetags in window: 10
../../../../_images/products_qblox_instruments_tutorials_QTM_130_scope_mode_qtm_35_1.png

Low-latency burst mode#

The TDC inside the QTM has an internal buffer of 16 entries and can send detection events to the FPGA at a rate of 44 ns per timetag. This creates a buffer budget of 704 ns, or the total time available for the TDC to buffer timetags.

If the oversampling of the TDC is disabled, any pulse sequences arriving at the QTM faster than a period of 44 ns will be handled correctly. This means the QTM can timetag with a lower latency until the total time buffer is filled up.

Here we will repeat the initial scope acquisition, except we will acquire a signal with a click rate faster than the 44 ns latency limit of the QTM.

Note that the QTM can acquire timetags at this reduced latency, but only in short bursts that are determined by the buffer memory of the TDC module inside.

Setting up the QTM to perform low latency time-tagging#

[21]:
# Reset the cluster before we begin this measurement scheme.
cluster.reset()
print(cluster.get_system_status())
Status: ERROR, Flags: FEEDBACK_NETWORK_CALIBRATION_FAILED, Slot flags: SLOT12_FEEDBACK_NETWORK_CALIBRATION_FAILED

Here we disable the oversampling of the TDC in both quads of the QTM which will enable us to carry out a burst measurement.

[22]:
# The channels in the QTM are grouped into two quads with four channels, that are indexed as
# 0 and 1 respectively.
# The TDC oversampling can be disabled at the quad level using the following command.
module.quad0.timetag_oversampling("disabled")
module.quad1.timetag_oversampling("disabled")

Here we configure the two channels again to acquire signals in windowed scope mode.

[23]:
# Synchronize the sequencers that will be used.
module.sequencer0.sync_en(True)
module.sequencer4.sync_en(True)
[24]:
# Enable the output channel to be controlled by the sequencer
module.io_channel0.mode("output")

Configure the input channel#

[25]:
# Disable the output mode of the channel we want to acquire with, and set the threshold
# mode for detection.
module.io_channel4.mode("input")
module.io_channel4.analog_threshold(THRESHOLD)

module.io_channel4.scope_trigger_mode("sequencer")

module.io_channel4.scope_mode("timetags-windowed")
[26]:
PULSE_DURATION: int = 4  # ns
WAIT_TIME: int = 16  # ns

We will now define a Q1ASM program to produce a series of TTL pulses that arrive with a higher frequency. In this case we have set the wait time to be 16 ns and the pulse duration to be 4 ns. This results in the spacing between successive rising edges to be 20 ns, which is well below the 44 ns latency limit.

We will transmit 26 pulses, which we hope to timetag with 20 ps precision, demonstrating the low latency burst mode.

[27]:
# Define parameter that determines the number of pulses.
NUM_PULSE: int = 26
[28]:
program_pulse_high_rate = f"""

    move                {WAIT_TIME}, R0
    move                {NUM_PULSE}, R1
    wait_sync           4

    loop:

        set_digital     1,1,0               # level (1=high), mask (always 1 for v1), fine_delay in steps of 1/128 ns
        upd_param       {PULSE_DURATION}
        set_digital     0,1,0

        upd_param       {WAIT_TIME}
        loop            R1, @loop
    stop
"""

We will use the same acquisition program as before, and trigger a windowed scope acquisition.

[29]:
ACQUISITION_DURATION: int = 500  # ns
[30]:
# Define acquisition program.
program_acq = f"""
    move        0, R0
    move        0, R2
    wait_sync   4

    acquire_timetags 0, R0, 1, R2, 4 # Arguments in order are: acquisition index, bin index, window open(1)/close(0), fine_delay, duration
    wait             {ACQUISITION_DURATION}
    acquire_timetags 0, R0, 0, R2, 4

    stop
"""
[31]:
# Define acquisition dictionary.
acquisition_modes: dict[str, dict[str, int]] = {"timetag_single": {"num_bins": 1, "index": 0}}
[32]:
# Upload the pulse program to the sequencer.
module.sequencer0.sequence(
    {
        "waveforms": {},
        "weights": {},
        "acquisitions": {},
        "program": program_pulse_high_rate,
    }
)

# Upload the acquisitions program to the sequencer.
module.sequencer4.sequence(
    {
        "waveforms": {},
        "weights": {},
        "acquisitions": acquisition_modes,
        "program": program_acq,
    }
)
[33]:
module.sequencer0.arm_sequencer()
module.sequencer4.arm_sequencer()
[34]:
module.start_sequencer()
[35]:
# Check sequencer status.
print(module.get_sequencer_status(0))
print(module.get_sequencer_status(4))
Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: NONE, Warning Flags: NONE, Error Flags: NONE, Log: []
Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: ACQ_BINNING_DONE, Warning Flags: NONE, Error Flags: NONE, Log: []
[36]:
# Wait for the acquisition to finish with a timeout period of one minute.
print(module.get_acquisition_status(4, 1))
# Extract scope data.
if cluster.is_dummy:
    with open("dependencies/130_low_latency_timetagging_dataset.pkl", "rb") as f:
        # dummy data if the dummy cluster is used
        scope_data_low_latency = pkl.load(f)
else:
    scope_data_low_latency = module.io_channel4.get_scope_data()
True

Let us check how many timetags we acquired in the window. We expect to see (NUM_PULSE + 2) = 28 timetags, NUM_PULSE coming from each rising edge and 2 from the opening and closing of the acquisition window.

[37]:
print(f"Number of acquired timetags in window: {len(scope_data_low_latency)}")

# Convert the units of timetags to ns.
timetags_low_latency = np.array(
    [(i[1] - scope_data_low_latency[0][1]) / (2048) for i in scope_data_low_latency]
)  # in ns
Number of acquired timetags in window: 28

The acquired timetags of the high frequency signal are displayed below.

[38]:
timetags_to_plot = timetags_low_latency[0:10]

fig, ax = plt.subplots(1, 1)

for i in range(len(timetags_to_plot)):
    if scope_data_low_latency[i][0] == "OPEN":
        ax.axvline(timetags_to_plot[i], color="g", label="Window open")

    elif scope_data_low_latency[i][0] == "CLOSE":
        ax.axvline(timetags_to_plot[i], color="r", label="Window close")

    elif scope_data_low_latency[i][0] == "RISE":
        ax.plot(timetags_to_plot[i], 1.0, marker="o", color="blue")

ax.set_xlabel(r"Time (ns)")
ax.set_ylabel("Photon detection")
ax.minorticks_on()
# Major grid (default style)
ax.grid(which="major", linestyle="-", axis="x")
# Minor grid (customized style)
ax.grid(which="minor", linestyle=":", axis="x")

plt.legend(loc="upper right")


plt.show()
../../../../_images/products_qblox_instruments_tutorials_QTM_130_scope_mode_qtm_63_0.png