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 |
|---|---|
|
|
|
Zero-indexed bit position where this sequencer writes its thresholded bit (must be even) |
|
Payload size in bytes |
|
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:
Calibrating the acquisition threshold and rotation for each sequencer.
Acquiring a pulse (or nothing) based on a configurable boolean.
Transmitting the thresholded result to a receiving sequencer via write-combine.
Reading back the register and verifying correctness.
Hardware requirements#
A Qblox Cluster with at least 1 QRM-RF in loopback (Output 1 → Input 1).
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-RF modules
modules = cluster.get_connected_modules(lambda mod: mod.is_qrm_type and 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()
# Enable marker switches to toggle the RF switch before output port
module.sequencer0.marker_ovr_en(True)
module.sequencer0.marker_ovr_value(3)
# module.sequencer0.connect_sequencer("io0")
module.sequencer0.connect_out0("IQ")
module.sequencer0.connect_acq("in0")
module.sequencer0.mod_en_awg(True)
module.sequencer0.demod_en_acq(True)
module.sequencer0.nco_prop_delay_comp_en(True)
module.out0_in0_lo_freq(3e9)
module.sequencer0.nco_freq(50e6)
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: 155 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_module8_sequencer0: rotation=292.85°, threshold=6.7211
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:
|
Register |
Meaning |
|---|---|---|
|
|
Valid bit set, data bit = 1 (pulse detected) |
|
|
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
io0and writes its thresholded bit toBIT_POS_SEQ0.Sequencer 2 acquires on
io1and writes its thresholded bit toBIT_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_posvalid 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 withinPAYLOAD_LENGTHbytes.
[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()
# Both sequencers share io0 (the only RF port); NCO frequency differentiates their channels.
module.sequencer0.connect_sequencer("io0")
module.sequencer2.connect_sequencer("io0")
module.sequencer0.nco_freq(100e6)
module.sequencer2.nco_freq(400e6)
module.sequencer0.mod_en_awg(True)
module.sequencer0.demod_en_acq(True)
module.sequencer2.mod_en_awg(True)
module.sequencer2.demod_en_acq(True)
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_module8_sequencer0: rotation=54.92°, threshold=5.0789
cluster0_module8_sequencer2: rotation=276.15°, threshold=2.2985
[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: []