See also

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

Combining Thresholded Bits via LINQ#

In quantum computing workflows, multiple qubits are often measured simultaneously and their binary readout results must be forwarded to a feedback sequencer as quickly as possible. LINQ-based feedback enables low-latency communication between sequencers, and the write-combine feature extends this by merging multiple simultaneously measured bits into a single data payload — reducing the number of messages the receiver must handle.

After enabling thresholded bit sharing with fb_acq_tb_id, configure write-combine with:

fb_acq_tb_cfg <write_combine: I1>, <bit_pos: I10>, <length: I7>, <duration: I16>

Parameter

Description

write_combine

1 to enable; 0 to disable write-combine mode

bit_pos

Zero-indexed bit position where this sequencer writes its thresholded bit (must be even)

length

Payload size in bytes

duration

Instruction duration in nanoseconds

When write-combine is enabled, the sequencer writes its thresholded measurement result to bit_pos in the shared payload. The bit immediately to the left (bit_pos + 1) is the valid bit, set to 1 by default when the acquisition completes. You can change this with fb_acq_tb_valid — see the Q1ASM commands list for details.

When multiple sequencers write to the same routing ID at the same time with write-combine enabled, their results are merged into a single payload according to each sequencer’s bit_pos. For more background, see the LINQ-based feedback documentation.

This tutorial demonstrates write-combine in a loopback setup and covers:

  1. Calibrating the acquisition threshold and rotation for each sequencer.

  2. Acquiring a pulse (or nothing) based on a configurable boolean.

  3. Transmitting the thresholded result to a receiving sequencer via write-combine.

  4. Reading back the register and verifying correctness.

Hardware requirements#

  • A Qblox Cluster with at least 1 QRM in loopback (Output 1 → Input 1, Output 2 → Input 2).

Setup#

First, we import the required packages and connect to the instrument.

[1]:
import numpy as np  # noqa: I001
from __future__ import annotations

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: OKAY, Flags: NONE, Slot flags: NONE

Get connected modules#

[4]:
# QRM baseband modules
modules = cluster.get_connected_modules(lambda mod: mod.is_qrm_type and not mod.is_rf_type)
# This uses the module of the correct type with the lowest slot index
module = list(modules.values())[0]
[5]:
module.disconnect_outputs()
module.disconnect_inputs()
module.sequencer0.nco_prop_delay_comp_en(True)

module.sequencer0.mod_en_awg(True)
module.sequencer0.demod_en_acq(True)

# module.sequencer0.connect_sequencer("io0_1")
module.sequencer0.connect_out0("I")
module.sequencer0.connect_out1("Q")
module.sequencer0.connect_acq_I("in0")
module.sequencer0.connect_acq_Q("in1")

Calibrating time of flight#

The time of flight (TOF) is the delay between a play command being executed and the pulse arriving at its input.

We measure it by playing a pulse and capturing the input with the scope mode. The index of the first scope sample that exceeds half the peak amplitude gives the arrival time.

[6]:
module.sequencer0.sequence(
    {
        "waveforms": {},
        "program": """
            set_awg_offs 15000, 0    # start pulse
            acquire      0, 0, 16384 # non-blocking: scope runs while pulse is on
            set_awg_offs 0, 0
            upd_param    4           # mandatory 4 ns update cycle
            stop
        """,
        "acquisitions": {"single": {"index": 0, "num_bins": 1}},
        "weights": {},
    }
)
module.sequencer0.arm_sequencer()
module.start_sequencer()

module.store_scope_acquisition(0, "single")

if cluster_ip is None:
    TIME_OF_FLIGHT = 300
else:
    scope_data = np.array(
        module.get_acquisitions(0)["single"]["acquisition"]["scope"]["path0"]["data"]
    )
    peak = np.max(scope_data)
    TIME_OF_FLIGHT = int(np.where(np.abs(scope_data) > peak / 2)[0][0])
    print(f"TOF: {TIME_OF_FLIGHT} ns")
TOF: 152 ns

Calibrating the threshold#

Cable length, LO frequency, and NCO frequency rotate the integrated IQ result away from the real axis, shifting the classifier boundary. Without correcting for this rotation the hardware cannot threshold reliably.

The calibration plays a pulse of PULSE_LENGTH nanoseconds, waits TIME_OF_FLIGHT for the pulse to arrive at the input, then integrates for PULSE_LENGTH nanoseconds. The angle of the resulting IQ vector gives the rotation; the threshold is placed at half the aligned amplitude, midway between the no-pulse and full-pulse responses.

[7]:
PULSE_LENGTH = 20

module.sequencer0.sequence(
    {
        "waveforms": {},
        "program": f"""
            set_awg_offs 15000, 0
            upd_param    {PULSE_LENGTH}                      # hold pulse for integration window
            set_awg_offs 0, 0
            upd_param    {TIME_OF_FLIGHT - PULSE_LENGTH - 4} # wait for signal arrival; -4 for preceding upd_param
            acquire      0, 0, {PULSE_LENGTH}                # integrate over pulse duration
            stop
        """,
        "acquisitions": {"single": {"index": 0, "num_bins": 1}},
        "weights": {},
    }
)
module.sequencer0.integration_length_acq(PULSE_LENGTH)
module.sequencer0.arm_sequencer()
module.start_sequencer()

if cluster_ip is not None:
    acq = module.sequencer0.get_acquisitions()["single"]["acquisition"]["bins"]["integration"]
    iq = acq["path0"][0] + 1j * acq["path1"][0]
    rotation = np.mod(-np.angle(iq), 2 * np.pi)  # counter-rotation to align IQ to real axis
    threshold = (
        np.exp(1j * rotation) * iq
    ).real / 2  # midpoint between 0-response and full-pulse response
    module.sequencer0.thresholded_acq_threshold(threshold)
    module.sequencer0.thresholded_acq_rotation(np.degrees(rotation))  # API expects degrees
    print(
        f"{module.sequencer0.name}: rotation={np.degrees(rotation):.2f}°, threshold={threshold:.4f}"
    )
cluster0_module4_sequencer0: rotation=355.34°, threshold=1.7878

Write combine with one sequencer#

We demonstrate the write-combine feature with a single sending sequencer:

  • Sequencer 0 plays (or skips) a pulse, acquires it, and writes the thresholded result to bit 0 of a 1-byte data payload.

  • Sequencer 1 receives the payload from sequencer 0 and stores it in register R0.

Each sender occupies two bits in the payload: the data bit at bit_pos and the valid bit at bit_pos + 1. Expected results with bit_pos = 0:

SEND_PULSE

Register R0 (binary)

Meaning

True

00000011

Valid bit set, data bit = 1 (pulse detected)

False

00000010

Valid bit set, data bit = 0 (no pulse)

Note: The table above assumes the default parameters below. If you change BIT_POS, the bit pattern will shift accordingly.

Experiment parameters#

[8]:
SEND_PULSE = True  # Toggle to test both acquisition outcomes

ID = 16  # Routing ID shared between sender and receiver
BIT_POS = 0  # Bit position for sequencer 0's thresholded result (must be even)

Configure routing#

set_local_route configures the cluster router to broadcast packets tagged with ID to all sequencers within this module. Sequencer 1 will receive what sequencer 0 sends because ID = 16 falls in the intra-cast range (16–255).

[9]:
cluster.clear_router()
module.set_local_route(ID)

Configure module and sequencers#

[10]:
module.sequencer0.sync_en(True)
module.sequencer1.sync_en(True)

# integration_length_acq controls when the LINQ message is transmitted.
module.sequencer0.integration_length_acq(PULSE_LENGTH)

Define Q1ASM programs#

[11]:
offs = 10000 if SEND_PULSE else 0  # ~30% of full-scale AWG offset

# Sender: declares its routing ID and write-combine configuration, then acquires.
prog_sender = f"""
    fb_acq_tb_id    {ID}, 4                           # Route thresholded bits to ID after the acquisition
    fb_acq_tb_cfg   1, {BIT_POS}, 1, 4                # write_combine=1, bit_pos={BIT_POS}, length=1 byte, duration=4 ns
    wait_sync       4                                 # Align timelines before the acquisition window
    set_awg_offs    {offs}, 0
    upd_param       {PULSE_LENGTH}
    set_awg_offs    0, 0
    upd_param       {TIME_OF_FLIGHT - PULSE_LENGTH - 4}
    acquire         0, 0, {PULSE_LENGTH}               # Acquire and threshold; result → bit {BIT_POS}
    stop
"""

# Receiver: waits for the payload to arrive, then pops it into R0.
prog_receiver = f"""
    wait_sync       4                                  # Align timelines with sequencer 0
    wait            700                                # Conservative bound: PULSE_LENGTH + threshold latency + LINQ routing
    fb_pop_data     {ID}, R0                           # Pop combined payload into R0
    stop
"""

Upload sequences#

[12]:
module.sequencer0.sequence(
    {
        "waveforms": {},
        "program": prog_sender,
        "acquisitions": {"single": {"index": 0, "num_bins": 1}},
        "weights": {},
    }
)

module.sequencer1.sequence(
    {
        "waveforms": {},
        "program": prog_receiver,
        "acquisitions": {},
        "weights": {},
    }
)

Run the experiment#

[13]:
module.arm_sequencer(0)
module.arm_sequencer(1)
module.start_sequencer()

print("Sequencer 0 status:", module.get_sequencer_status(0))
print("Sequencer 1 status:", module.get_sequencer_status(1))
Sequencer 0 status: Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: ACQ_SCOPE_DONE_PATH_0, ACQ_SCOPE_DONE_PATH_1, ACQ_BINNING_DONE, Warning Flags: NONE, Error Flags: NONE, Log: []
Sequencer 1 status: Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: NONE, Warning Flags: NONE, Error Flags: NONE, Log: []

Verify result#

Read register R0 from the receiving sequencer and compare against the expected value.

[14]:
write_combine_byte = module.sequencer1.get_register("R0")
expected = (0b11 if SEND_PULSE else 0b10) << BIT_POS

print(f"SEND_PULSE = {SEND_PULSE}")
print(f"Received : {write_combine_byte:08b}")
print(f"Expected : {expected:08b}")
print(
    "✓ PASS"
    if write_combine_byte == expected
    else "✗ FAIL — check threshold and loopback connection"
)
SEND_PULSE = True
Received : 00000011
Expected : 00000011
✓ PASS

Write-combine with two sequencers#

The single-sender example above showed write-combine in isolation. Here we extend it to two sender sequencers running on separate I/O channels:

  • Sequencer 0 acquires on io0 and writes its thresholded bit to BIT_POS_SEQ0.

  • Sequencer 2 acquires on io1 and writes its thresholded bit to BIT_POS_SEQ2.

  • Sequencer 1 receives the combined payload into register R0.

Because both senders share the same ID and send at the same time, the hardware merges their results into a single byte before the receiver pops it. Each sender contributes:

  • data bit at bit_pos

  • valid bit at bit_pos + 1

With the default BIT_POS_SEQ0 = 0 and BIT_POS_SEQ2 = 2, the received byte layout is:

bit 7

bit 6

bit 5

bit 4

bit 3

bit 2

bit 1

bit 0

valid_s2

data_s2

valid_s0

data_s0

Experiment parameters#

Toggle the pulse flags and bit positions freely. The expected byte is computed automatically from your choices, so the verification step never needs updating.

Note: Each sender occupies 2 bits (data + valid), so bit positions must be even and non-overlapping, and 2 * (max_bit_pos // 2 + 1) must fit within PAYLOAD_LENGTH bytes.

[15]:
SEND_PULSE_SEQ0 = True  # Toggle pulse for sequencer 0 (io0)
SEND_PULSE_SEQ2 = True  # Toggle pulse for sequencer 2 (io1)

BIT_POS_SEQ0 = 0  # Bit position in payload for sequencer 0's TB (must be even)
BIT_POS_SEQ2 = 2  # Bit position in payload for sequencer 2's TB (must be even)

PAYLOAD_LENGTH = 1  # Total payload size in bytes (increase if bit positions require it)

ID = 16

Validate configuration#

We check that the chosen bit positions are valid before uploading anything to the hardware.

[16]:
assert BIT_POS_SEQ0 % 2 == 0, "BIT_POS_SEQ0 must be even (data bit + valid bit pair)"
assert BIT_POS_SEQ2 % 2 == 0, "BIT_POS_SEQ2 must be even (data bit + valid bit pair)"
assert BIT_POS_SEQ0 != BIT_POS_SEQ2, "Bit positions must not overlap"
assert max(BIT_POS_SEQ0, BIT_POS_SEQ2) + 2 <= PAYLOAD_LENGTH * 8, (
    f"Bit positions exceed payload length of {PAYLOAD_LENGTH} byte(s). "
    f"Increase PAYLOAD_LENGTH or reduce bit positions."
)

Compute expected result#

We build the expected byte from first principles so the verification step requires no manual updates when parameters change.

[17]:
def build_expected_byte(
    send_pulse_seq0: bool, send_pulse_seq2: bool, bit_pos_seq0: int, bit_pos_seq2: int
) -> int:
    """Construct the expected payload byte given pulse flags and bit positions."""
    result = (0b11 if send_pulse_seq0 else 0b10) << bit_pos_seq0
    result |= (0b11 if send_pulse_seq2 else 0b10) << bit_pos_seq2
    return result


expected_byte = build_expected_byte(SEND_PULSE_SEQ0, SEND_PULSE_SEQ2, BIT_POS_SEQ0, BIT_POS_SEQ2)
print(f"Configuration : SEND_PULSE_SEQ0 = {SEND_PULSE_SEQ0}, SEND_PULSE_SEQ2 = {SEND_PULSE_SEQ2}")
print(f"Bit positions : SEQ0 → bit {BIT_POS_SEQ0}, SEQ2 → bit {BIT_POS_SEQ2}")
print(f"Expected byte : {expected_byte:08b}")
Configuration : SEND_PULSE_SEQ0 = True, SEND_PULSE_SEQ2 = True
Bit positions : SEQ0 → bit 0, SEQ2 → bit 2
Expected byte : 00001111

Configure routing#

[18]:
cluster.clear_router()
module.set_local_route(ID)

Configure module and sequencers#

[19]:
module.disconnect_inputs()
module.disconnect_outputs()
# Connect each sender to its own I/O channel.
module.sequencer0.connect_sequencer("io0")
module.sequencer2.connect_sequencer("io1")

Calibrate threshold#

The hardware is now reconfigured (new connections and NCO frequencies), so we recalibrate both senders.

[20]:
for seq in module.sequencers:
    seq.sync_en(False)

_cal_prog = f"""
    set_awg_offs 10000, 0
    upd_param    {PULSE_LENGTH}
    set_awg_offs 0, 0
    upd_param    {TIME_OF_FLIGHT - PULSE_LENGTH - 4}
    acquire      0, 0, {PULSE_LENGTH}
    stop
"""
for seq in [module.sequencer0, module.sequencer2]:
    seq.sequence(
        {
            "waveforms": {},
            "program": _cal_prog,
            "acquisitions": {"single": {"index": 0, "num_bins": 1}},
            "weights": {},
        }
    )
    seq.integration_length_acq(PULSE_LENGTH)
    seq.arm_sequencer()
module.start_sequencer()

for seq in [module.sequencer0, module.sequencer2]:
    acq = seq.get_acquisitions()["single"]["acquisition"]["bins"]["integration"]
    result = acq["path0"][0] + 1j * acq["path1"][0]
    rotation = np.mod(-np.angle(result), 2 * np.pi)
    threshold = (np.exp(1j * rotation) * result).real / 2
    if np.isnan(threshold):
        print(f"{seq.name}: NaN — check loopback connection and/or pulse amplitude.")
    else:
        seq.thresholded_acq_threshold(threshold)
        seq.thresholded_acq_rotation(rotation * 360 / (2 * np.pi))
        print(f"{seq.name}: rotation={np.degrees(rotation):.2f}°, threshold={threshold:.4f}")
cluster0_module4_sequencer0: rotation=0.00°, threshold=1.1081
cluster0_module4_sequencer2: rotation=0.00°, threshold=1.4263
[21]:
for seq in [module.sequencer0, module.sequencer1, module.sequencer2]:
    seq.sync_en(True)

for seq in [module.sequencer0, module.sequencer2]:
    seq.integration_length_acq(PULSE_LENGTH)

Define Q1ASM programs#

[22]:
def make_sender_program(
    event_id: int,
    bit_pos: int,
    payload_length: int,
    send_pulse: bool,
    time_of_flight: int,
    pulse_length: int,
) -> str:
    """Build a sender Q1ASM program that writes to bit_pos of the shared payload."""
    offs = 10000 if send_pulse else 0
    prog = f"""
    fb_acq_tb_id    {event_id}, 4
    fb_acq_tb_cfg   1, {bit_pos}, {payload_length}, 4    # write_combine=1, bit_pos={bit_pos}, length={payload_length} byte(s), duration=4 ns
    wait_sync       4
    set_awg_offs    {offs}, 0
    upd_param       {pulse_length}
    set_awg_offs    0, 0
    upd_param       {time_of_flight - pulse_length - 4}
    acquire         0, 0, {pulse_length}                 # Acquire and threshold; result → bit {bit_pos}
    stop
"""
    return prog


prog_sender0 = make_sender_program(
    ID, BIT_POS_SEQ0, PAYLOAD_LENGTH, SEND_PULSE_SEQ0, TIME_OF_FLIGHT, PULSE_LENGTH
)
prog_sender2 = make_sender_program(
    ID, BIT_POS_SEQ2, PAYLOAD_LENGTH, SEND_PULSE_SEQ2, TIME_OF_FLIGHT, PULSE_LENGTH
)

# Both senders share the same ID, so a single pop retrieves the merged byte.
prog_receiver = f"""
    wait_sync       4                                    # Align timelines with sequencers 0 and 2
    wait            600                                  # Wait for both results to merge
    fb_pop_data     {ID}, R0                             # Pop combined payload into R0
    stop
"""

Upload sequences#

[23]:
module.sequencer0.sequence(
    {
        "waveforms": {},
        "program": prog_sender0,
        "acquisitions": {"single": {"index": 0, "num_bins": 1}},
        "weights": {},
    }
)

module.sequencer1.sequence(
    {
        "waveforms": {},
        "program": prog_receiver,
        "acquisitions": {},
        "weights": {},
    }
)

module.sequencer2.sequence(
    {
        "waveforms": {},
        "program": prog_sender2,
        "acquisitions": {"single": {"index": 0, "num_bins": 1}},
        "weights": {},
    }
)

Run the experiment#

[24]:
for seq_idx in [0, 1, 2]:
    module.arm_sequencer(seq_idx)
module.start_sequencer()

for seq_idx in [0, 1, 2]:
    print(f"Sequencer {seq_idx} status:", module.get_sequencer_status(seq_idx))
Sequencer 0 status: Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: ACQ_SCOPE_DONE_PATH_0, ACQ_SCOPE_DONE_PATH_1, ACQ_BINNING_DONE, Warning Flags: NONE, Error Flags: NONE, Log: []
Sequencer 1 status: Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: NONE, Warning Flags: NONE, Error Flags: NONE, Log: []
Sequencer 2 status: Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: ACQ_BINNING_DONE, Warning Flags: NONE, Error Flags: NONE, Log: []

Verify result#

Read register R0 from the receiving sequencer. Both sender contributions should be visible at their respective bit positions.

[25]:
write_combine_byte = module.sequencer1.get_register("R0")

print(f"SEND_PULSE_SEQ0={SEND_PULSE_SEQ0}, BIT_POS_SEQ0={BIT_POS_SEQ0}")
print(f"SEND_PULSE_SEQ2={SEND_PULSE_SEQ2}, BIT_POS_SEQ2={BIT_POS_SEQ2}")
print(f"Received : {write_combine_byte:08b}")
print(f"Expected : {expected_byte:08b}")
print(
    "✓ PASS"
    if write_combine_byte == expected_byte
    else "✗ FAIL — check thresholds, loopback connections, and bit position assignments"
)
SEND_PULSE_SEQ0=True, BIT_POS_SEQ0=0
SEND_PULSE_SEQ2=True, BIT_POS_SEQ2=2
Received : 00001111
Expected : 00001111
✓ PASS

Stop#

Finally, let’s stop the sequencers if they haven’t already and close the instrument connection. One can also display a detailed snapshot containing the instrument parameters before closing the connection by uncommenting the corresponding lines.

[26]:
# Stop all sequencers.
module.stop_sequencer()

# Print status of sequencers 0 and 1 (should now say it is stopped).
print(module.get_sequencer_status(0))
print(module.get_sequencer_status(1))
print()
Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: FORCED_STOP, ACQ_SCOPE_DONE_PATH_0, ACQ_SCOPE_DONE_PATH_1, ACQ_BINNING_DONE, Warning Flags: NONE, Error Flags: NONE, Log: []
Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: FORCED_STOP, Warning Flags: NONE, Error Flags: NONE, Log: []