Developer Guide: Writing a SubSequence
0. Overview
This guide explains how to write your own SubSequence subclass from scratch.
After reading this guide you will be able to:
Define the
ParameterClassdataclass that declares which parameters your sequence needsWrite a minimal
SubSequencethat plays QUA code using those parametersUse element-wise
ParameterMapparameters to scale a sequence to multiple gatesOverride the lifecycle hooks (
qua_declare,qua_before_sweep,qua_before_sequence,qua_after_sequence) to structure more complex sequencesCompose sequences by nesting one
SubSequenceinside anotherWrite the configuration dictionary that connects parameter values to the sequence class
The guide uses SquarePulse, SquarePulseScalable, ToControlPoint and Cpmg as concrete examples throughout.
1. The SubSequence Contract
A SubSequence is a self-contained, device-agnostic snippet of QUA code. When you subclass it you are responsible for two things:
PARAMETER_CLASS — a frozen dataclass that inherits from ParameterClass and declares all parameters your sequence needs. The framework reads this at construction time, matches the field names against the configuration, and populates self.arbok_params with the corresponding QCoDeS parameters.
qua_sequence — the method that contains the actual QUA commands. It is called once per shot (inside the infinite loop arbok wraps around every measurement). Parameters are accessed as self.arbok_params.<field_name>.qua, which returns either a Python literal or a QUA variable depending on whether that parameter is being swept.
Everything else — QCoDeS parameter registration, sweep variable declaration, stream management, the infinite loop — is handled by the framework.
2. A Minimal SubSequence
2.1 The ParameterClass
Every SubSequence subclass must declare a PARAMETER_CLASS attribute that points to a frozen dataclass inheriting from ParameterClass. Each field in the dataclass is one parameter the sequence needs. The field type determines the QCoDeS unit, the QUA variable type, and any validators.
The available built-in parameter types are:
Type |
QUA type |
Unit |
Use for |
|---|---|---|---|
|
|
|
Wait durations, pulse widths |
|
|
|
Gate voltages, amplitudes |
|
|
— |
Dimensionless amplitude scalings |
|
|
|
any frequencies |
|
|
— |
Loop counters, repetitions, etc |
|
|
— |
QUA element names |
|
— |
— |
Lists of element names |
|
— |
— |
Per-element voltages or values |
from dataclasses import dataclass
from arbok_driver import ParameterClass
from arbok_driver.parameter_types import Amplitude, String, Time
@dataclass(frozen=True)
class SquarePulseParameters(ParameterClass):
amplitude: Amplitude
element: String
t_ramp: Time
t_square_pulse: Time
2.2 The SubSequence class
With the ParameterClass defined, the SubSequence subclass only needs to set PARAMETER_CLASS, annotate arbok_params, and implement qua_sequence. Everything else is inherited.
from qm import qua
from arbok_driver import SubSequence
class SquarePulse(SubSequence):
"""
Plays a square pulse: ramp up → wait → ramp down on a single gate element.
"""
PARAMETER_CLASS = SquarePulseParameters
arbok_params: SquarePulseParameters
def qua_sequence(self):
"""Macro played within the qua.program() context."""
qua.align()
qua.play(
pulse='ramp' * qua.amp(self.arbok_params.amplitude.qua),
element=self.arbok_params.element.qua,
duration=self.arbok_params.t_ramp.qua,
)
qua.wait(
self.arbok_params.t_square_pulse.qua,
self.arbok_params.element.qua,
)
qua.play(
pulse='ramp' * qua.amp(self.arbok_params.amplitude.qua),
element=self.arbok_params.element.qua,
duration=self.arbok_params.t_ramp.qua,
)
2.3 The configuration dictionary
The configuration dictionary is the only place where device-specific values live. It is kept separate from the class so the same SquarePulse class can be reused on any device by supplying a different config.
Each entry under parameters must contain a type key (one of the parameter types listed above) and a value key. An optional label key sets the axis label used in data saving.
from arbok_driver.parameter_types import Amplitude, String, Time
square_conf = {
'parameters': {
'amplitude': {'type': Amplitude, 'value': 0.5},
'element': {'type': String, 'value': 'gate_1'},
't_ramp': {'type': Time, 'value': 20},
't_square_pulse': {'type': Time, 'value': 100},
}
}
2.4 Instantiation and inspection
A SubSequence always takes a parent (Measurement or another SubSequence), a name, and the configuration dict. After construction all parameters are accessible as QCoDeS parameters on the instance.
from rich import print as rprint
from arbok_driver import ArbokDriver, Device, Measurement
from arbok_driver.examples.configurations.hardware import (
opx1000_config, divider_config)
mock_device = Device(
name='mock_device',
opx_config=opx1000_config,
divider_config=divider_config,
)
qm_driver = ArbokDriver('qm_driver', mock_device)
mock_measurement = Measurement(qm_driver, 'mock_measurement')
square_pulse = SquarePulse(mock_measurement, 'square_pulse', square_conf)
square_pulse.print_readable_snapshot()
rprint(mock_measurement.get_qua_program_as_str())
The compiled output inlines the values directly. Notice the infinite_loop_ and pause that arbok wraps around every sequence — those are always added by the framework and should not be written inside qua_sequence.
3. Scaling to Multiple Gates with ParameterMap
When a sequence must operate on multiple gates simultaneously, use ParameterMap[str, T] instead of a single typed parameter. The framework creates one QCoDeS parameter per element listed under the elements key in the config, and groups them into a mapping accessible as self.arbok_params.<field_name>[element].
The arbok.ramp helper uses these maps to issue one play call per element automatically, so the QUA code does not need to iterate over elements explicitly.
Below is SquarePulseScalable, which extends SquarePulse to operate on any number of gates.
from dataclasses import dataclass
from qm import qua
from arbok_driver import arbok, SubSequence, ParameterClass
from arbok_driver.parameter_types import List, Time, ParameterMap, Voltage
@dataclass(frozen=True)
class SquarePulseScalableParameters(ParameterClass):
sticky_elements: List # list of gate element names
t_ramp: Time
t_square_pulse: Time
v_home: ParameterMap[str, Voltage] # one Voltage param per element
v_square: ParameterMap[str, Voltage] # one Voltage param per element
class SquarePulseScalable(SubSequence):
PARAMETER_CLASS = SquarePulseScalableParameters
arbok_params: SquarePulseScalableParameters
def qua_sequence(self):
qua.align(*self.arbok_params.sticky_elements.qua)
arbok.ramp(
elements=self.arbok_params.sticky_elements.qua,
reference=self.arbok_params.v_home,
target=self.arbok_params.v_square,
duration=self.arbok_params.t_ramp,
operation='unit_ramp',
)
qua.wait(
self.arbok_params.t_square_pulse.qua,
*self.arbok_params.sticky_elements.qua,
)
arbok.ramp(
elements=self.arbok_params.sticky_elements.qua,
reference=self.arbok_params.v_square,
target=self.arbok_params.v_home,
duration=self.arbok_params.t_ramp,
operation='unit_ramp',
)
The configuration declares the element-wise parameters using an elements dict instead of a single value:
square_scalable_conf = {
'parameters': {
'sticky_elements': {
'type': List,
'value': ['P1', 'P2', 'J1'],
},
't_ramp': {'type': Time, 'value': 20},
't_square_pulse': {'type': Time, 'value': 100},
'v_home': {
'type': Voltage,
'label': 'Home voltage',
'elements': {
'P1': 0.0,
'P2': 0.0,
'J1': 0.0,
},
},
'v_square': {
'type': Voltage,
'label': 'Square pulse voltage',
'elements': {
'P1': 0.1,
'P2': -0.05,
'J1': 0.08,
},
},
}
}
This creates individual QCoDeS parameters v_home_P1, v_home_P2, v_home_J1, etc., while keeping the QUA code completely unchanged. Scaling from 2 to 8 gates requires only updating the config.
meas_scaled = Measurement(qm_driver, 'meas_scaled')
sq_scalable = SquarePulseScalable(meas_scaled, 'sq_scalable', square_scalable_conf)
sq_scalable.print_readable_snapshot()
4. Lifecycle Hooks
Beyond qua_sequence, the framework calls several optional hooks at specific points during compilation. Override only the ones your sequence actually needs. The execution order is:
qua_declare ← once, before the infinite loop (declare QUA variables)
qua_before_sweep ← once per shot, before any sweep loops begin
[ sweep loops ]
qua_before_sequence ← once per sweep point, before the core sequence
qua_sequence ← the core pulse sequence
qua_after_sequence ← once per sweep point, after the core sequence
Always call super().<hook>() inside any hook you override. The base class propagates the call to all nested sub-sequences.
Hook |
When to use |
|---|---|
|
Declare QUA variables your sequence needs across shots (e.g. loop counters, accumulation buffers) |
|
Set or reset values that belong outside the sweep loop (e.g. re-zero a running average before each outer loop) |
|
Apply per-shot setup that depends on swept values (e.g. compute a derived QUA variable from current sweep params) |
|
Save or stream results after each shot |
The Cpmg sequence is a good example. It declares loop counters in qua_declare, precomputes a wait-time scaling factor in qua_before_sequence (which depends on the swept repetitions variable), and only then runs the pulse train in qua_sequence.
from dataclasses import dataclass
from qm import qua
from qm.qua.lib import Cast
from arbok_driver import SubSequence, ParameterClass
from arbok_driver.parameter_types import Time, Int, List
@dataclass(frozen=True)
class EchoParameters(ParameterClass):
elements: List
qubit_element: Int # single element for illustration
repetitions: Int
t_wait: Time
class Echo(SubSequence):
"""
Minimal Hahn-echo-style sequence: (wait - pi - wait) x N
Illustrates how to use qua_declare and qua_before_sequence.
"""
PARAMETER_CLASS = EchoParameters
arbok_params: EchoParameters
def qua_declare(self):
"""Declare QUA variables needed across shots."""
super().qua_declare() # always call super first
self.n_index = qua.declare(int)
# QUA does not support integer division. We compute 1/(repetitions*2)
# as a fixed-point factor and then multiply the integer time with it
# using Cast.mul_int_by_fixed (same approach as Cpmg).
self.wait_factor = qua.declare(qua.fixed)
self.sub_wait = qua.declare(int)
def qua_before_sequence(self):
"""Compute derived variables that depend on swept parameters."""
super().qua_before_sequence()
# Store 1 / (repetitions * 2) as a fixed-point number …
qua.assign(
self.wait_factor,
1 / (self.arbok_params.repetitions.qua * 2),
)
# … then multiply the integer time by that factor
qua.assign(
self.sub_wait,
Cast.mul_int_by_fixed(
self.arbok_params.t_wait.qua, self.wait_factor),
)
def qua_sequence(self):
qua.align(*self.arbok_params.elements.qua)
with qua.for_(
var=self.n_index,
init=0,
cond=self.n_index < self.arbok_params.repetitions.qua,
update=self.n_index + 1,
):
qua.wait(self.sub_wait, *self.arbok_params.elements.qua)
qua.play('pi_pulse', self.arbok_params.qubit_element.qua)
qua.wait(self.sub_wait, *self.arbok_params.elements.qua)
qua.align(*self.arbok_params.elements.qua)
5. Composing Sequences by Nesting
A SubSequence can own other SubSequences as building blocks. When you instantiate a child inside a parent’s __init__, arbok automatically appends it to self._sub_sequences. That list drives arbok’s automatic nesting behaviour — it calls qua_declare, qua_sequence, etc. on every entry in the list during compilation.\n”,
“\n”,
“When you want to control the child manually (call its methods at specific points in your own qua_sequence), you must opt out of automatic nesting. The pattern is:\n”,
“\n”,
“1. Instantiate all children normally. They register themselves into self._sub_sequences.\n”,
“2. Save a reference to the populated list.\n”,
“3. Reset self._sub_sequences = [] to prevent arbok from auto-nesting them.\n”,
“4. Call each child’s lifecycle hooks explicitly in your own hooks.\n”,
“\n”,
“StateProjection is the canonical example: it builds several single-qubit gate sub-sequences, saves them in self.single_qubit_gates, empties self._sub_sequences, and then calls sub_sequence.qua_declare() / qua_before_sequence() / qua_gate() manually at the right points.
from dataclasses import dataclass
from qm import qua
from arbok_driver import SubSequence, ParameterClass
from arbok_driver.parameter_types import List, Time
from arbok_driver.examples.sequences import ToControlPoint
@dataclass(frozen=True)
class InitThenPulseParameters(ParameterClass):
gate_elements: List
qubit_elements: List
t_pulse: Time
# ToControlPoint's parameters must also be present in this config
class InitThenPulse(SubSequence):
"""
Runs a ToControlPoint sub-sequence, then waits for t_pulse.
Demonstrates the manual-nesting pattern.
"""
PARAMETER_CLASS = InitThenPulseParameters
arbok_params: InitThenPulseParameters
def __init__(self, parent, name: str, sequence_config: dict | None = None):
super().__init__(parent, name, sequence_config)
# 1. Instantiate child — this appends it to self._sub_sequences
self.to_ctrl = ToControlPoint(self, 'to_ctrl', sequence_config)
# 2. Reset the list so arbok does NOT auto-nest to_ctrl
self._sub_sequences = []
self.elements = list(self.arbok_params.gate_elements.get())
self.elements += list(self.arbok_params.qubit_elements.get())
def qua_declare(self):
super().qua_declare()
self.to_ctrl.qua_declare() # propagate manually
def qua_before_sequence(self):
super().qua_before_sequence()
self.to_ctrl.qua_before_sequence() # propagate manually
def qua_sequence(self):
# Call child at the right point in the timeline
self.to_ctrl.qua_sequence()
qua.wait(
self.arbok_params.t_pulse.qua,
*self.elements,
)
The child’s parameters are sourced from the same configuration dict as the parent. As long as the parent config includes all fields required by the child’s PARAMETER_CLASS, arbok resolves them automatically via map_arbok_params.
6. When to Override __init__
The base SubSequence.__init__ handles all framework wiring. You only need to override it when your sequence must do work that depends on the already-constructed parameters before compilation starts. Common reasons:
Collecting the flat list of QUA elements for
qua.align/qua.waitcalls.Instantiating child sub-sequences.
Injecting default parameter values programmatically (see
Cpmg).
When you do override __init__, always call super().__init__ with parent, name, and sequence_config before any access to self.arbok_params, because the framework populates that attribute inside super().__init__.
from dataclasses import dataclass
from arbok_driver import SubSequence, ParameterClass
from arbok_driver.parameter_types import List, Time, ParameterMap, Voltage
@dataclass(frozen=True)
class ToControlPointParameters(ParameterClass):
gate_elements: List
qubit_elements: List
t_ramp_to_control: Time
t_wait_pre_control: Time
v_home: ParameterMap[str, Voltage]
v_control: ParameterMap[str, Voltage]
class ToControlPoint(SubSequence):
PARAMETER_CLASS = ToControlPointParameters
arbok_params: ToControlPointParameters
def __init__(self, parent, name: str, sequence_config: dict | None = None):
super().__init__(parent, name, sequence_config) # must come first
# self.arbok_params is now populated, so we can read from it
self.elements = list(self.arbok_params.gate_elements.get())
self.elements += list(self.arbok_params.qubit_elements.get())
def qua_sequence(self):
from arbok_driver import arbok
qua.align(*self.elements)
arbok.ramp(
elements=self.arbok_params.gate_elements.get(),
reference=self.arbok_params.v_home,
target=self.arbok_params.v_control,
duration=self.arbok_params.t_ramp_to_control,
operation='unit_ramp',
)
qua.align(*self.elements)
qua.wait(self.arbok_params.t_wait_pre_control.qua, *self.elements)
qua.align(*self.elements)
7. Checklist for a New SubSequence
Use this checklist when writing a new SubSequence subclass.
ParameterClass
Defined as a
@dataclass(frozen=True)inheriting fromParameterClass.Every field has a type annotation from
arbok_driver.parameter_types.Use
ParameterMap[str, T]for parameters that are defined per gate element.
Class body
PARAMETER_CLASSis set to the dataclass defined above.arbok_paramsis annotated with the same dataclass type (for IDE autocompletion).qua_sequencecontains onlyqua.*calls andarbok.*helper calls.Parameters inside
qua_sequenceare always accessed via.qua, never via.get().
__init__ (only when needed)
super().__init__(parent, name, sequence_config)is the first call.Post-super code that reads from
arbok_paramsuses.get()(Python side), not.qua(QUA side).
Hooks (only when needed)
super().<hook>()is always the first call inside any hook.qua_declaredeclares QUA variables withqua.declare(...)and stores them as instance attributes.qua_before_sequenceassigns values to QUA variables usingqua.assign(...).
Configuration
Every field name in
PARAMETER_CLASSappears underparametersin the config.Single-value parameters use
{'type': ..., 'value': ...}.Element-wise parameters use
{'type': ..., 'label': ..., 'elements': {'gate': value, ...}}.
8. Complete Minimal Example
The following cells bring everything together as the smallest working SubSequence: a voltage ramp to a target point, a wait, and a reset.
from dataclasses import dataclass
from qm import qua
from arbok_driver import arbok, SubSequence, ParameterClass
from arbok_driver.parameter_types import List, Time, ParameterMap, Voltage
@dataclass(frozen=True)
class RampAndWaitParameters(ParameterClass):
gate_elements: List
t_ramp: Time
t_hold: Time
v_home: ParameterMap[str, Voltage]
v_target: ParameterMap[str, Voltage]
class RampAndWait(SubSequence):
"""
Ramps all gate elements from v_home to v_target, holds for t_hold, then resets.
"""
PARAMETER_CLASS = RampAndWaitParameters
arbok_params: RampAndWaitParameters
def __init__(self, parent, name: str, sequence_config: dict | None = None):
super().__init__(parent, name, sequence_config)
self.elements = list(self.arbok_params.gate_elements.get())
def qua_sequence(self):
qua.align(*self.elements)
arbok.ramp(
elements=self.arbok_params.gate_elements.get(),
reference=self.arbok_params.v_home,
target=self.arbok_params.v_target,
duration=self.arbok_params.t_ramp,
operation='unit_ramp',
)
qua.align(*self.elements)
qua.wait(self.arbok_params.t_hold.qua, *self.elements)
qua.align(*self.elements)
arbok.reset_sticky_elements(self.arbok_params.gate_elements.get())
2026-04-16 05:32:41,538 - qm - INFO - Starting session: 9a1b4cd9-e8a3-4d54-acda-e6072bc6475c
ramp_conf = {
'parameters': {
'gate_elements': {'type': List, 'value': ['P1', 'P2', 'J1']},
't_ramp': {'type': Time, 'value': 100},
't_hold': {'type': Time, 'value': 5000},
'v_home': {
'type': Voltage,
'label': 'Home voltage',
'elements': {'P1': 0.0, 'P2': 0.0, 'J1': 0.0},
},
'v_target': {
'type': Voltage,
'label': 'Target voltage',
'elements': {'P1': 0.15, 'P2': -0.1, 'J1': 0.05},
},
}
}
from arbok_driver import ArbokDriver, Device, Measurement
from arbok_driver.examples.configurations.hardware import (
opx1000_config, divider_config)
from rich import print as rprint
mock_device = Device(
name='mock_device',
opx_config=opx1000_config,
divider_config=divider_config,
)
qm_driver = ArbokDriver('qm_driver', mock_device)
mock_measurement = Measurement(qm_driver, 'mock_measurement')
ramp_and_wait = RampAndWait(mock_measurement, 'ramp_and_wait', ramp_conf)
ramp_and_wait.print_readable_snapshot()
rprint(mock_measurement.get_qua_program_as_str())
qm_driver_mock_measurement_ramp_and_wait:
parameter value
--------------------------------------------------------------------------------
gate_elements : ['P1', 'P2', 'J1'] (N/A)
t_hold : 5000 (s)
t_ramp : 100 (s)
v_home_J1 : 0 (V)
v_home_P1 : 0 (V)
v_home_P2 : 0 (V)
v_target_J1 : 0.05 (V)
v_target_P1 : 0.15 (V)
v_target_P2 : -0.1 (V)
# Single QUA script generated at 2026-04-16 05:32:45.212522 # QUA library version: 1.2.5 from qm import CompilerOptionArguments from qm.qua import * with program() as prog: v1 = declare(int, value=0) with infinite_loop_(): pause() assign(v1, 0) align("P1", "P2", "J1") align("P1", "P2", "J1") play("unit_ramp"*amp(0.8999999999999999), "P1", duration=100) play("unit_ramp"*amp(-0.6000000000000001), "P2", duration=100) play("unit_ramp"*amp(0.05), "J1", duration=100) align("P1", "P2", "J1") align("P1", "P2", "J1") wait(5000, "P1", "P2", "J1") align("P1", "P2", "J1") ramp_to_zero("P1") ramp_to_zero("P2") ramp_to_zero("J1") align("P1", "P2", "J1") align() assign(v1, (v1+1)) r1 = declare_stream() save(v1, r1) align() with stream_processing(): r1.buffer(1).save("qm_driver_mock_measurement_shots") config = None loaded_config = None