See also
A Jupyter notebook version of this tutorial can be downloaded here.
Sharing Measurement Results with LINQ-based Feedback#
In this tutorial, we demonstrate LINQ-based feedback of data from the demodulation path of a readout sequencer, which allows sequencers to exchange acquisition data with low latency.
We cover two data types in this tutorial:
I/Q Data — The (weighted) integrated result triggered by the
acquireoracquire_weightedQ1ASM instruction.Thresholded Bits (TB) — a single bit indicating whether the I/Q value exceeded a threshold line in the I/Q plane.
More information can be found in the LINQ-based feedback documentation.
Key Commands:
Command |
Purpose |
|---|---|
|
Share thresholded bits under |
|
Share I/Q data under |
|
Pop the next payload matching |
|
Pop the top FIFO entry; store its ID and payload separately |
ID ranges:
0: disable sending1–15: self-cast (same sequencer)16–255: intra-cast / multi-cast (other sequencers on same or different modules)
Hardware Requirements:
A Qblox Cluster with one QRM connected 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 IPython.display import Markdown, display
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")
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=352.73°, threshold=1.7865
Experiment parameters#
[8]:
ID = 16
Sharing thresholded bits (TB) with intra-cast#
Sequencer 0 optionally plays a square pulse, acquires it, thresholds the result, and intra-casts the thresholded bit to Sequencer 1.
When sharing TB, the hardware packs the result into a 2-bit field:
bit 0 (LSB): thresholded bit (1 = above threshold, 0 = below threshold)
bit 1: valid bit (always 1 by default, confirming the acquisition has completed)
Based on the result of the TB:
Pulse sent (above threshold) → received payload =
0b00000011(valid=1, data=1)No pulse (below threshold) → received payload =
0b00000010(valid=1, data=0)
The valid bit can be changed with fb_acq_tb_valid <valid>, <duration>. Multiple TB measurements can be packed using the write-combine feature (covered in the write-combine tutorial).
Configure module#
[9]:
# Prior to configuring the routing of the module, reset the router paths of the Cluster to default
cluster.clear_router()
# With ID chosen as 16, it falls in the intra-cast range (16–255); set_local_route routes packets tagged with
# that ID to all sequencers within this module (sequencer 1 will receive the LINQ data).
module.set_local_route(ID)
# integration_length_acq controls when the LINQ message is transmitted.
module.sequencer0.integration_length_acq(PULSE_LENGTH)
# Enable sync for both sequencers so wait_sync aligns their timelines
module.sequencer0.sync_en(True)
module.sequencer1.sync_en(True)
Experiment parameters#
[10]:
SEND_PULSE = True # Toggle to test both acquisition outcomes
Define sender Q1ASM program#
[11]:
offs = 10000 if SEND_PULSE else 0
# Sender: shares TB under ID, enables write-combine at the configured bit position.
prog_tb_sender = f"""
fb_acq_tb_id {ID}, 4 # Share thresholded bits under ID
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 & threshold
stop
"""
Define receiver Q1ASM program#
[12]:
prog_tb_receiver = f"""
wait_sync 4 # Synchronize with sequencer 0
wait 600 # Allow time for data to arrive
fb_pop_data {ID}, R0 # Pop TB payload into R0
stop
"""
Upload sequences#
[13]:
module.sequencer0.sequence(
{
"waveforms": {},
"program": prog_tb_sender,
"acquisitions": {"single": {"index": 0, "num_bins": 1}},
"weights": {},
}
)
module.sequencer1.sequence(
{
"waveforms": {},
"program": prog_tb_receiver,
"acquisitions": {},
"weights": {},
}
)
Run the experiment#
[14]:
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#
[15]:
tb_result = module.sequencer1.get_register("R0")
expected_tb = "00000011" if SEND_PULSE else "00000010"
print(f"SEND_PULSE = {SEND_PULSE}")
print(f"Received : {tb_result:08b}")
print(f"Expected : {expected_tb}")
print(
"PASS"
if f"{tb_result:08b}" == expected_tb
else "FAIL - check threshold and loopback connection"
)
SEND_PULSE = True
Received : 00000011
Expected : 00000011
PASS
Sharing raw I/Q values with intra-cast#
Instead of a thresholded bit, we can share the raw I/Q values. The hardware sends I and Q as two consecutive 32-bit LINQ payloads tagged with the same ID, always I first and Q second. Two sequential fb_pop_data calls retrieve them in that order:
First
fb_pop_datacall → I valueSecond
fb_pop_datacall → Q value
Experiment parameters#
[16]:
SEND_PULSE = True # Reset for IQ section; toggle to test both acquisition outcomes
Define Q1ASM programs#
[17]:
offs = 10000 if SEND_PULSE else 0
# Sender: shares IQ values under ID after each acquisition.
prog_iq_sender = f"""
fb_acq_iq_id {ID}, 4 # Enable I/Q sharing under ID after each acquisition
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 I and Q values
stop
"""
Define receiver Q1ASM program#
[18]:
prog_iq_receiver = f"""
wait_sync 4 # Synchronize with sequencer 0
wait 600 # Allow time for LINQ data to arrive
fb_pop_data {ID}, R0 # Pop I value into R0
fb_pop_data {ID}, R1 # Pop Q value into R1
stop
"""
Upload sequences#
[19]:
module.sequencer0.sequence(
{
"waveforms": {},
"program": prog_iq_sender,
"acquisitions": {"single": {"index": 0, "num_bins": 1}},
"weights": {},
}
)
module.sequencer1.sequence(
{
"waveforms": {},
"program": prog_iq_receiver,
"acquisitions": {},
"weights": {},
}
)
Run the experiment#
[20]:
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#
[21]:
linq_val_i = module.sequencer1.get_register("R0")
linq_val_q = module.sequencer1.get_register("R1")
print(f"SEND_PULSE = {SEND_PULSE}")
print(f"I (R0): {linq_val_i}")
print(f"Q (R1): {linq_val_q}")
SEND_PULSE = True
I (R0): 9236064
Q (R1): 1901663
What do these values mean?#
The LINQ I/Q payload is a 32-bit signed integer encoding the same measurement as the hardware integration path. For an integration-path value \(v\) returned by get_acquisitions, the LINQ register value satisfies:
To recover the integration-path value from a LINQ register, apply the inverse:
The table below applies both directions using the register values from the I/Q run above. The \(\div 2^{22}\) column converts the raw LINQ integer to an integration-path float; the \(\times 2^{22}\) column converts the get_acquisitions float back to a LINQ integer. All four columns should be consistent.
[22]:
_acq = module.sequencer0.get_acquisitions()["single"]["acquisition"]["bins"]["integration"]
intg_i, intg_q = _acq["path0"][0], _acq["path1"][0]
def to_norm(v: int) -> float:
"""Convert raw LINQ integer to integration-path float."""
return v / 2**22
def to_linq(v: float) -> int:
"""Convert integration-path float to raw LINQ integer."""
return int(v * 2**22) if not np.isnan(v) else 0
rows = [("I", linq_val_i, intg_i), ("Q", linq_val_q, intg_q)]
table = "| | raw LINQ | $\\div 2^{22}$ | get\\_acq | $\\times 2^{22}$ |\n"
table += "|---|---:|---:|---:|---:|\n"
for name, linq, intg in rows:
table += f"| {name} | {linq} | {to_norm(linq):.5f} | {intg:.5f} | {to_linq(intg)} |\n"
display(Markdown(table))
print(
"PASS"
if to_linq(intg_i) == linq_val_i and to_linq(intg_q) == linq_val_q
else "FAIL - check acquisition and LINQ configuration"
)
raw LINQ |
\(\div 2^{22}\) |
get_acq |
\(\times 2^{22}\) |
|
|---|---|---|---|---|
I |
9236064 |
2.20205 |
2.20205 |
9236064 |
Q |
1901663 |
0.45339 |
0.45339 |
1901663 |
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.
[23]:
# 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: []