Quick Start Guide
This guide walks through the full workflow for designing and running NMR experiments with qeg-nmr-qua, from basic configuration through to 3D parameter sweeps.
Setup and Configuration
All experiments start with an ExperimentSettings
object. This is the single source of truth for pulse, frequency, and readout parameters.
The OPX hardware configuration is then generated automatically from those settings:
from qualang_tools.units import unit
from pathlib import Path
import qeg_nmr_qua as qnmr
u = unit(coerce_to_integer=True)
settings = qnmr.ExperimentSettings(
n_avg=4,
pulse_length=1.1 * u.us,
pulse_amplitude=0.422, # 0.5 * Vpp
rotation_angle=248.0, # degrees
thermal_reset=4 * u.s,
center_freq=282.1901 * u.MHz,
offset_freq=4500 * u.Hz,
readout_delay=20 * u.us,
dwell_time=4 * u.us,
readout_start=0 * u.us,
readout_end=256 * u.us,
save_dir=Path("results"),
)
cfg = qnmr.cfg_from_settings(settings)
Settings are validated on update; call settings.validate() directly if you change
values manually. All time values are in nanoseconds internally; use the unit
helper to express them in physical units.
Building a Pulse Sequence
Experiments are constructed by appending commands to an experiment object. Four command types are available:
Method |
Effect |
|---|---|
|
RF pulse with optional phase, amplitude, or length |
|
Free-evolution delay |
|
Align one or more elements in time |
|
Repeating multi-pulse / delay block |
Commands are translated into QUA code in FIFO order when
create_experiment() is called.
1D Experiment — Free Induction Decay
Experiment1D measures the FID following
a single π/2 pulse. No swept parameter is needed; every command must use
scalar (non-iterable) arguments.
expt = qnmr.Experiment1D(settings=settings, config=cfg)
# A single π/2 pulse on the resonator element
expt.add_pulse(name=settings.pi_half_key, element=settings.res_key)
expt.execute_experiment()
# Inspect data
import numpy as np
I = np.array(expt.save_data_dict["I_data"])
Q = np.array(expt.save_data_dict["Q_data"])
Note
If you pass an iterable (e.g. a NumPy array) to any argument of a command in a
Experiment1D, validate_experiment() will raise a ValueError before the
QUA program is compiled.
2D Experiment — Sweeping One Parameter
Experiment2D loops over a single
swept variable vector. Pass a NumPy array to one argument of any command and
the class records it as the sweep axis.
The loop nesting order is: averages (outer) → swept variable → readout scan (inner-most). This means the sweep is traversed in full before the next average begins, which helps mitigate slow parameter drifts.
Example: pulse-amplitude calibration
import numpy as np
import qeg_nmr_qua as qnmr
amp_list = np.arange(0.93, 1.05, 0.01) # amplitude rescaling factors
expt = qnmr.Experiment2D(settings=settings, config=cfg)
# Sweep the amplitude — every add_pulse call with the same array
# is automatically recognised as the same loop variable.
expt.add_pulse(
name=settings.pi_half_key,
element=settings.res_key,
amplitude=amp_list,
)
# Optionally convert the raw rescaling factor to physical units for plots
expt.update_sweep_axis(amp_list * settings.pulse_amplitude) # volts
expt.update_sweep_label("Pulse Amplitude (Vpp)")
expt.execute_experiment()
I_data = expt.save_data_dict["I_data"] # shape (n_sweep, n_time)
Example: Floquet (Hamiltonian-engineering) sweep
from qualang_tools.units import unit
u = unit(coerce_to_integer=True)
t0 = 5 * u.us
p1 = settings.pulse_length
thlf = (t0 - p1) // 2
t1 = t0 - p1
t2 = 2 * t1 - p1
pine8_phases = np.array([0, 0, 0, 0, 180, 180, 180, 180])
pine8_delays = np.array([thlf, t2, t1, t2, t1, t2, t1, t2, thlf])
period_list = np.arange(0, 25, 1) # 0 to 24 Floquet periods
expt = qnmr.Experiment2D(settings=settings, config=cfg)
expt.add_frame_change(angle=5.50, element=settings.res_key)
expt.add_floquet_sequence(
phases=pine8_phases,
delays=pine8_delays,
repetitions=period_list, # iterable → sweep variable
)
expt.add_delay(1 * u.ms) # T1 filter
expt.add_pulse(name=settings.pi_half_key, element=settings.res_key)
expt.update_sweep_axis(period_list)
expt.update_sweep_label("Pine-8 Periods")
expt.execute_experiment()
Consistency rule
All iterable arguments across all commands in a Experiment2D must either be
the same array or a constant multiple of that array. Mixing incompatible
vectors raises a ValueError.
3D Experiment — Sweeping Two Parameters
Experiment3D extends the 2D framework
to two independent swept variables. Understanding the loop layer convention is
essential to getting the correct data shape and avoiding subtle bugs.
The loop nesting order is: averages (outer) → swept variable 1 (slow) → swept variable 2 (fast) → readout scan (inner-most). This means that current live_plot implementations will take a long time to update if the product of the two sweep dimensions is large, since the full inner loop must be traversed before the next point in the
outer loop is reached.
Loop layer convention
The loop_layer keyword argument is 1-based and controls which QUA loop variable
a command’s iterable is bound to.
|
|
QUA variable |
Role in the loop nest |
|---|---|---|---|
|
|
|
Outer (slow) sweep — changes less frequently |
|
|
|
Inner (fast) sweep — changes most frequently |
The full loop nest executes as:
for average in range(n_avg): # outermost
for value_outer in var_vec_lst[0]: # layer 1 — slow sweep
for value_inner in var_vec_lst[1]: # layer 2 — fast sweep
<pulse sequence>
<readout>
The resulting data arrays have shape
(n_outer, n_inner, n_time) after averaging.
Example: joint amplitude + delay sweep
import numpy as np
import qeg_nmr_qua as qnmr
from qualang_tools.units import unit
u = unit(coerce_to_integer=True)
# Layer 1 (outer / slow): vary the delay between pulses
# Layer 2 (inner / fast): vary the pulse amplitude
delay_list = np.array([1, 2, 4, 8, 16, 32]) * u.us # 6 outer points
amp_list = np.arange(0.95, 1.06, 0.01) # 11 inner points
expt = qnmr.Experiment3D(settings=settings, config=cfg)
# --- Pulse 1: amplitude is swept (inner loop, layer 2) ---
expt.add_pulse(
name=settings.pi_half_key,
element=settings.res_key,
amplitude=amp_list,
loop_layer=2, # <-- inner / fast sweep
)
# --- Delay: duration is swept (outer loop, layer 1) ---
expt.add_delay(delay_list, loop_layer=1) # <-- outer / slow sweep
# --- Pulse 2: same amplitude sweep must be consistent with layer 2 ---
expt.add_pulse(
name=settings.pi_half_key,
element=settings.res_key,
amplitude=amp_list,
loop_layer=2,
)
expt.update_sweep_axis_outer(delay_list / u.us)
expt.update_sweep_label_outer("Delay (µs)")
expt.update_sweep_axis_inner(amp_list * settings.pulse_amplitude)
expt.update_sweep_label_inner("Pulse Amplitude (Vpp)")
expt.execute_experiment()
# Data shape: (n_outer=6, n_inner=11, n_time)
I_data = expt.save_data_dict["I_data"]
Warning
Assigning the wrong loop layer is a silent data-ordering error.
If you intend a variable to be the slow sweep but assign
loop_layer=2 instead of loop_layer=1, the QUA program will
compile and run without error but the data axes will be transposed.
Always match loop_layer=1 to var_vec_lst[0] (outer loop) and
loop_layer=2 to var_vec_lst[1] (inner loop).
Omitting loop_layer in a 3D experiment
When loop_layer is not specified (default -1), the experiment
automatically assigns the variable to the first unfilled slot:
The first iterable encountered fills layer 1 (outer).
The second distinct iterable encountered fills layer 2 (inner).
A second call using an array that is a scalar multiple of an existing layer’s vector is merged into that same layer.
This auto-assignment is convenient for simple cases but explicit ``loop_layer`` values are strongly recommended for 3D experiments to prevent accidental ordering mistakes.
Checking the generated QUA program
Before running on hardware, you can inspect the compiled QUA program offline:
from pathlib import Path
expt.compile_to_qua(
offline=True,
save_path=Path("compiled_experiment.qua"),
)
This writes a plain-text file containing the full QUA script and does not require a hardware connection. Use it to verify pulse ordering, timing, and that sweep variables appear in the expected loop positions.
Saving Data
Data is persisted automatically at the end of
execute_experiment() via
DataSaver. You can also trigger a manual
save at any point:
expt.save_data(experiment_prefix="pulse_cal")
This creates a folder pulse_cal_0001/ (auto-incremented) containing:
config.json— OPX hardware configurationsettings.json— Experiment settingscommands.json— Serialised command listdata.json— Averaged I/Q data and metadata
Next Steps
Experiment Module — Complete API reference and design notes for subclass extensions
Configuration Module — Configuration and settings API