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:

  1. I/Q Data — The (weighted) integrated result triggered by the acquire or acquire_weighted Q1ASM instruction.

  2. 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 sending

  • 1–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_data call → I value

  • Second fb_pop_data call → 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()
../../../../_images/products_qblox_instruments_tutorials_QRC_422_linq_acquisition_50_0.png

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: []