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:
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 | |—|—| | fb_acq_tb_id <ID>, <duration> | Share thresholded bits under <ID> after every acquisition | | fb_acq_iq_id <ID>, <duration> | Share I/Q data under <ID> after every acquisition | | fb_pop_data <ID>, <dest> | Pop the next payload matching <ID> into register <dest> | | fb_pull_data <id_dest>, <dest> | 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 QRC connected in loopback mode (Output 1 → Input 1).
Setup#
First, we import the required packages and connect to the instrument.
[1]:
import matplotlib.pyplot as plt # noqa: I001 - Ruff will otherwise put below and it won't be included in generation.
import numpy as np # noqa: I001 - Ruff will otherwise put below and it won't be included in generation.
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: ERROR, Flags: FEEDBACK_NETWORK_CALIBRATION_FAILED, Slot flags: NONE
Get connected modules#
[4]:
# QRC modules
modules = cluster.get_connected_modules(lambda mod: mod.is_qrc_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.connect_sequencer("io0")
module.sequencer0.nco_prop_delay_comp_en(True)
module.out0_att(20)
module.in0_att(20)
module.out0_in0_lo_freq(1e9)
module.sequencer0.nco_freq(10e6)
# The QRC has more input gain than other modules. To avoid overdriving the ADC, we set extra attenuation.
Calibrating the Threshold#
Cable length, LO and NCO frequency rotate the integrated IQ result away from the real axis, shifting the classifier boundary. Calibration measures and corrects for this offset; skipping it means the data bits in your write-combine payload will be unreliable.
For details on thresholded measurements, see the rotation and thresholding documentation.
[6]:
def calibrate_threshold(tof: int, pulse_length: int, active_sequencers: list) -> None:
"""Calibrate threshold and rotation for each sequencer."""
for sequencer in module.sequencers:
sequencer.sync_en(False)
prog = f"""
set_awg_offs 15000, 0
upd_param {tof - 4}
set_awg_offs 0, 0
acquire 0, 0, {pulse_length}
stop
"""
for sequencer in active_sequencers:
sequencer.sequence(
{
"waveforms": {},
"program": prog,
"acquisitions": {"single": {"index": 0, "num_bins": 1}},
"weights": {},
}
)
sequencer.arm_sequencer()
module.start_sequencer()
for sequencer in active_sequencers:
acq = sequencer.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"{sequencer.name}: calibration returned NaN — "
"check loopback connection and pulse amplitude."
)
else:
sequencer.thresholded_acq_threshold(threshold)
sequencer.thresholded_acq_rotation(rotation * 360 / (2 * np.pi))
print(
f"{sequencer.name}: rotation = {np.degrees(rotation):.2f}°, threshold = {threshold:.4f}"
)
Experiment Parameters#
[7]:
PULSE_LENGTH = 100
TIME_OF_FLIGHT = 149
ID = 16
calibrate_threshold(TIME_OF_FLIGHT, PULSE_LENGTH, [module.sequencer0])
cluster0_module12_sequencer0: rotation = 242.48°, threshold = 9.2428
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 completed)
This gives:
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#
[8]:
# Reset router paths to default
cluster.clear_router()
# ID 16 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)
# Set the integration length of all acquire commands to PULSE_LENGTH
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#
[9]:
SEND_PULSE = True # Toggle to test both acquisition outcomes
Define sender Q1ASM Program#
[10]:
offs = 15000 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 {TIME_OF_FLIGHT}
set_awg_offs 0,0
acquire 0, 0, {PULSE_LENGTH} # Acquire & threshold
stop
"""
Define receiver Q1ASM Program#
[11]:
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#
[12]:
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#
[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, ACQ_SCOPE_DONE_PATH_2, ACQ_SCOPE_DONE_PATH_3, 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#
[14]:
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 : 00000010
Expected : 00000011
✗ FAIL — check threshold and loopback connection
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#
[15]:
SEND_PULSE = True # Reset for IQ section; toggle to test both acquisition outcomes
Define Q1ASM Programs#
[16]:
offs = 15000 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 {TIME_OF_FLIGHT}
set_awg_offs 0,0
acquire 0, 0, {PULSE_LENGTH} # Acquire I and Q values
stop
"""
Define receiver Q1ASM Program#
[17]:
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#
[18]:
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#
[19]:
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, ACQ_SCOPE_DONE_PATH_2, ACQ_SCOPE_DONE_PATH_3, 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#
[20]:
i_linq = module.sequencer1.get_register("R0")
q_linq = module.sequencer1.get_register("R1")
print(f"SEND_PULSE = {SEND_PULSE}")
print(f"I (R0): {i_linq}")
print(f"Q (R1): {q_linq}")
SEND_PULSE = True
I (R0): 22517
Q (R1): 73692
What Do These Values Mean?#
The raw LINQ value is a large integer whose relationship to physical voltage depends on the module and its configuration. To understand this we sweep the AWG offset across its full range (−32768 to +32767) and record the LINQ values at each step.
We also compare against the integrated acquisition returned by get_acquisitions. This is the same underlying measurement, but the hardware accumulates and integrates across the integration window.
Run Calibration Sweep#
Rather than re-uploading the sequence for each amplitude step, all 32 steps are embedded in a single Q1ASM program. The sender sweeps through 32 DC offsets with set_awg_offs, storing each acquisition in its own bin. The receiver pops each pair of LINQ payloads into dedicated registers — I into R0–R31, Q into R32–R63 — so the entire sweep completes in a single hardware run.
[21]:
amplitudes = np.linspace(-1.0, 1.0, 32)
awg_ints = np.linspace(-32768, 32767, 32).astype(int)
# Sender: configure IQ sharing once, then iterate through all 32 offsets
p_send = f" fb_acq_iq_id {ID}, 4\n wait_sync 4\n"
for i, awg_int in enumerate(awg_ints):
p_send += (
f" set_awg_offs {awg_int}, 0\n"
f" upd_param {TIME_OF_FLIGHT}\n"
f" acquire 0, {i}, {PULSE_LENGTH}\n"
)
p_send += " stop\n"
# Receiver: pop I into R0–R31 and Q into R32–R63 for each sender step
p_recv = " wait_sync 4\n"
for i in range(32):
p_recv += (
f" wait 600\n"
f" fb_pop_data {ID}, R{i}\n"
f" fb_pop_data {ID}, R{i + 32}\n"
)
p_recv += " stop\n"
linq_vals_i, linq_vals_q, intg_vals, intg_vals_q = [], [], [], []
module.sequencer0.sequence(
{
"waveforms": {},
"program": p_send,
"acquisitions": {"single": {"index": 0, "num_bins": 32}},
"weights": {},
}
)
module.sequencer1.sequence(
{
"waveforms": {},
"program": p_recv,
"acquisitions": {},
"weights": {},
}
)
module.arm_sequencer(0)
module.arm_sequencer(1)
module.start_sequencer()
_intg = module.sequencer0.get_acquisitions()["single"]["acquisition"]["bins"]["integration"]
for i in range(32):
intg_vals.append(_intg["path0"][i])
linq_vals_i.append(module.sequencer1.get_register(f"R{i}"))
linq_vals_q.append(module.sequencer1.get_register(f"R{i + 32}"))
intg_vals_q.append(_intg["path1"][i])
Plot Results#
[22]:
def _fit(vals: list | np.ndarray) -> tuple[np.poly1d, np.ndarray]:
"""Return a degree-1 polynomial fit and its coefficients array."""
c = np.polyfit(amplitudes, vals, 1) # always length deg+1=2, no leading-zero stripping
return np.poly1d(c), c
def _plot_series(ax: plt.Axes, vals: list | np.ndarray, color: str, label: str) -> np.poly1d:
"""Scatter-plot vals and overlay its linear fit on ax."""
fit, c = _fit(vals)
ax.scatter(amplitudes, vals, color=color, zorder=5, s=50, label=f"{label} (measured)")
ax.plot(
amplitudes,
fit(amplitudes),
color=color,
linestyle="--",
linewidth=1.5,
label=f"{label} fit: {c[0]:.3g}·A + {c[1]:.3g}",
)
return fit
[23]:
def _magnitude(i: list, q: list) -> np.ndarray:
"""Return signed magnitude sqrt(I²+Q²), sign taken from amplitudes."""
return np.sqrt(np.array(i) ** 2 + np.array(q) ** 2) * np.sign(amplitudes)
linq_voltage = _magnitude(linq_vals_i, linq_vals_q)
intg_voltage = _magnitude(intg_vals, intg_vals_q)
BLUE, ORANGE, GREEN = "#3A7DC9", "#E86B2E", "#2CA02C"
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 4))
for ax, (i_vals, q_vals, v_vals), title in [
(ax1, (linq_vals_i, linq_vals_q, linq_voltage), "LINQ fb_pop_data"),
(ax2, (intg_vals, intg_vals_q, intg_voltage), "Hardware integration path"),
]:
ax.axhline(0, color="#888", linewidth=0.8, linestyle=":")
ax.axvline(0, color="#888", linewidth=0.8, linestyle=":")
for vals, color, label in [(i_vals, BLUE, "I"), (q_vals, ORANGE, "Q"), (v_vals, GREEN, "|IQ|")]:
_plot_series(ax, vals, color, label)
ax.set_xlabel("Waveform Amplitude (normalized)")
ax.set_ylabel("Value (raw units)")
ax.set_title(title)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.25)
fig.suptitle(
"Waveform Amplitude vs. Measured I/Q Values — LINQ vs. Integrated", fontweight="bold", y=1.01
)
plt.tight_layout()
plt.show()
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.
[24]:
# 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: ERROR, State: STOPPED, Exit Code: 0, Info Flags: FORCED_STOP, ACQ_SCOPE_DONE_PATH_0, ACQ_SCOPE_DONE_PATH_1, ACQ_BINNING_DONE, ACQ_SCOPE_DONE_PATH_2, ACQ_SCOPE_DONE_PATH_3, Warning Flags: NONE, Error Flags: SEQUENCE_PROCESSOR_DATA_OVERFLOW, Log: []
Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: FORCED_STOP, Warning Flags: NONE, Error Flags: NONE, Log: []