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 ParameterClass dataclass that declares which parameters your sequence needs

  • Write a minimal SubSequence that plays QUA code using those parameters

  • Use element-wise ParameterMap parameters to scale a sequence to multiple gates

  • Override the lifecycle hooks (qua_declare, qua_before_sweep, qua_before_sequence, qua_after_sequence) to structure more complex sequences

  • Compose sequences by nesting one SubSequence inside another

  • Write 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

Time

int

s (internally cycles)

Wait durations, pulse widths

Voltage

fixed

V

Gate voltages, amplitudes

Amplitude

fixed

Dimensionless amplitude scalings

Frequency

int

Hz

any frequencies

Int

int

Loop counters, repetitions, etc

String

str

QUA element names

List

Lists of element names

ParameterMap[str, T]

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

qua_declare

Declare QUA variables your sequence needs across shots (e.g. loop counters, accumulation buffers)

qua_before_sweep

Set or reset values that belong outside the sweep loop (e.g. re-zero a running average before each outer loop)

qua_before_sequence

Apply per-shot setup that depends on swept values (e.g. compute a derived QUA variable from current sweep params)

qua_after_sequence

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.wait calls.

  • 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 from ParameterClass.

  • 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_CLASS is set to the dataclass defined above.

  • arbok_params is annotated with the same dataclass type (for IDE autocompletion).

  • qua_sequence contains only qua.* calls and arbok.* helper calls.

  • Parameters inside qua_sequence are 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_params uses .get() (Python side), not .qua (QUA side).

Hooks (only when needed)

  • super().<hook>() is always the first call inside any hook.

  • qua_declare declares QUA variables with qua.declare(...) and stores them as instance attributes.

  • qua_before_sequence assigns values to QUA variables using qua.assign(...).

Configuration

  • Every field name in PARAMETER_CLASS appears under parameters in 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