Developer Guide: Writing Readout Classes and Readout Sequences
0. Overview
This guide explains how to write your own AbstractReadout subclasses, ReadSequence subclasses, and how the configuration ties them together.
After reading this guide you will be able to:
Write a minimal
AbstractReadoutthat performs a hardware measurement and exposes its result as aGettableParameterWrite a processing-only
AbstractReadoutthat consumes the result of an earlier readoutAdd settable parameters to a readout class
Use temporary QUA variables inside a readout class without polluting the global scope
Write a
ReadSequencethat composes readout groups into a full measurementWrite the configuration dictionary that connects readout classes to signals and sequences
The guide uses DcAverage, Difference, Threshold and DcChoppedReadout as concrete examples throughout.
Consider the block diagram while reading this guide to have a visual aid accompaning the content.
from IPython.display import display, HTML
display(HTML('<img src="figures/arbok_block_diagram.png" width="300">'))

1. The AbstractReadout Contract
Every readout class must inherit from AbstractReadout. The base class handles QCoDeS parameter registration, QUA variable declaration and stream management. As a developer you only need to implement three things:
__init__ receives and stores whatever the readout needs to do its job, calls the parent constructor, and then calls _create_gettables.
_create_gettables calls create_gettable for every result this readout will produce, and calls get_gettable_from_path for every result it needs to consume from an earlier readout.
qua_measure contains the QUA code that actually runs on the FPGA. It may only be called inside a qua.program() context.
There is one optional override: qua_declare_variables, which is called before the main program body. Override it when you need QUA variables that are local to your readout and are not produced as gettables.
1.1 The mandatory __init__ signature
The first five arguments of every AbstractReadout.__init__ are fixed and must always be forwarded to super().__init__ in this exact order. Any additional arguments specific to your readout come after these five.
from arbok_driver import AbstractReadout, ReadSequence, Signal
class MyReadout(AbstractReadout):
def __init__(
self,
name: str, # name of this readout entry, taken from the config key
read_sequence: ReadSequence, # the ReadSequence that owns this readout
signal: Signal, # the Signal this result belongs to
save_results: bool, # whether to stream results to the host
parameters: dict, # settable parameters dict from the config
# --- your custom arguments go here ---
qua_element: str,
):
super().__init__(
name=name,
read_sequence=read_sequence,
signal=signal,
save_results=save_results,
parameters=parameters,
)
self.qua_element = qua_element
self._create_gettables()
The framework instantiates your class automatically when building the readout groups from the configuration. The five mandatory arguments are injected by the framework. Everything inside kwargs in the configuration entry is passed as additional keyword arguments. This is why qua_element above must appear in kwargs in the config.
Always call _create_gettables at the end of __init__. The gettables must exist before qua_declare_variables is called by the framework at compile time.
2. Creating and Consuming Gettables
2.1 Producing a result with create_gettable
create_gettable allocates a QUA variable and registers it as a QCoDeS GettableParameter on the owning Signal. It takes two arguments, a name and a QUA variable type.
The gettable_name you supply becomes the suffix of the full dotted path that the user sees and that other readouts can reference. For DcAverage the gettable is named after the readout entry itself (i.e. self.name), which gives paths like parity_read.p1p2.ref__p1p2. You may choose any name that is meaningful for your readout. DcChoppedReadout creates three gettables per sensor with suffixes _read, _ref and _diff. Check their source code to have a look.
from arbok_driver.examples.readout_classes import DcAverage, DcChoppedReadout
The var_type argument accepts qua.fixed for a real-valued result, int for an integer or bool for a binary state.
from qm import qua
# Inside _create_gettables:
def _create_gettables(self):
# A single real-valued result named after this readout entry
self.current_gettable = self.create_gettable(
gettable_name=self.name,
var_type=qua.fixed,
)
# A binary classification result with a custom suffix
self.state_gettable = self.create_gettable(
gettable_name=f"{self.name}_state",
var_type=bool,
)
2.2 Consuming an earlier result with get_gettable_from_path
When a readout needs to use the QUA variable produced by an earlier readout, call get_gettable_from_path with the full dotted path string. The framework resolves this to the actual GettableParameter object at construction time. You then access its QUA variable via .qua_result_var inside qua_measure.
The dotted path follows the pattern <readsequence_name>.<signal_name>.<group_name>__<entry_key>. This path is what the user writes in the kwargs of the config, and what you pass to get_gettable_from_path.
# Inside _create_gettables of Difference:
def _create_gettables(self):
# Resolve the paths supplied in kwargs to actual GettableParameter objects
self.minuend_gettable = self.get_gettable_from_path(self.minuend)
self.subtrahend_gettable = self.get_gettable_from_path(self.subtrahend)
# Produce a new gettable for the result of this readout
self.difference_gettable = self.create_gettable(
gettable_name=self.name,
var_type=qua.fixed,
)
There are two important rules here. First, the readout whose result you are consuming must appear in an earlier readout group in qua_sequence, so that its QUA variable is populated before your qua_measure runs. The framework does not enforce this ordering, it is the responsibility of the ReadSequence author. Second, you must not call get_gettable_from_path inside qua_measure. Path resolution happens once at construction time, not at compile time.
3. Writing qua_measure
qua_measure is the only place where QUA commands should appear in your readout class. It is called by the ReadSequence when iterating over a readout group. The framework guarantees that all QUA variables have been declared before this method is called.
Access the QUA variable of a gettable via gettable.qua_result_var. This is the raw QuaVariable object that QUA operates on. It is the authors responsibility to populate this variable such that arbok can save and process it.
3.1 A hardware measurement readout
DcAverage shows the minimal pattern for a readout that talks to hardware. A single qua.measure call integrates the signal and stores it in the gettable’s QUA variable.
def qua_measure(self):
outputs = [
qua.integration.full('x_const', self.current_gettable.qua_result_var),
]
qua.measure('measure', self.qua_element, *outputs)
3.2 A processing-only readout
Difference and Threshold never call qua.measure. They perform pure arithmetic on QUA variables that earlier readouts have already populated. Threshold also reads from a ParameterClass attribute via .qua, which exposes the settable parameter as a QUA variable.
# Difference
def qua_measure(self):
qua.assign(
self.difference_gettable.qua_result_var,
self.minuend_gettable.qua_result_var - self.subtrahend_gettable.qua_result_var,
)
# Threshold
def qua_measure(self):
qua.assign(
self.state_gettable.qua_result_var,
self.current_gettable.qua_result_var > self.arbok_params.threshold.qua,
)
4. Adding Settable Parameters
When a readout needs user-settable parameters such as a threshold voltage or a number of repetitions, declare them using a ParameterClass dataclass. This makes them visible in QCoDeS snapshots and accessible as QUA variables inside qua_measure.
The pattern has three parts: define a frozen dataclass inheriting from ParameterClass, set PARAMETER_CLASS on your class, and annotate arbok_params with your dataclass type. After that, every field you declared is accessible as self.arbok_params.<field_name>.qua inside qua_measure.
from dataclasses import dataclass
from arbok_driver import ParameterClass, GettableParameter
from arbok_driver.parameter_types import Voltage, Int
@dataclass(frozen=True)
class MyReadoutParameters(ParameterClass):
threshold: Voltage # will appear as a settable QCoDeS parameter
class MyReadout(AbstractReadout):
PARAMETER_CLASS = MyReadoutParameters
arbok_params: MyReadoutParameters
state_gettable: GettableParameter
current_gettable: GettableParameter
def qua_measure(self):
# Access the threshold as a QUA variable
qua.assign(
self.state_gettable.qua_result_var,
self.current_gettable.qua_result_var > self.arbok_params.threshold.qua,
)
The values for these parameters are supplied in the parameters key of the readout’s configuration entry. If a readout has no settable parameters, omit PARAMETER_CLASS and arbok_params entirely, as DcAverage and Difference do.
5. Declaring Temporary QUA Variables
Sometimes a readout needs intermediate QUA variables that accumulate values across a loop but are not exposed as gettables. Declare these by overriding qua_declare_variables. Always call super().qua_declare_variables() first so that the base class can declare all gettable variables.
DcChoppedReadout illustrates this. It runs a QUA for_ loop over n_chops iterations, accumulating an average into temporary variables before writing the final result to the gettable variables.
def qua_declare_variables(self) -> None:
# Always call super first
super().qua_declare_variables()
# Declare a loop counter
self.qua_chop_nr = qua.declare(int)
# Declare per-sensor accumulation buffers
for sub_readout in self.readout_qua_elements:
self.ref_temp_vars[sub_readout] = qua.declare(qua.fixed)
self.read_temp_vars[sub_readout] = qua.declare(qua.fixed)
Variables declared here live in the top-level QUA variable scope. Give them names that are descriptive enough to avoid collisions if multiple instances of your class are used in the same measurement.
6. A Readout That Handles Multiple Sensors Internally
The examples so far create one gettable per readout entry. DcChoppedReadout takes a different approach: it receives a dictionary readout_qua_elements mapping sensor names to QUA element names, and creates three gettables per sensor (_ref, _read, _diff) all within a single readout entry. This is appropriate when a group of measurements are tightly coupled and must run together inside a single loop.
The key difference from simpler classes is that _create_gettables iterates over the sensors and calls create_gettable multiple times, storing the results in dictionaries keyed by sensor name.
def _create_gettables(self) -> None:
for sub_readout, _ in self.readout_qua_elements.items():
read = self.read_gettables[sub_readout] = self.create_gettable(
gettable_name=f"{sub_readout}_read",
var_type=qua.fixed,
)
ref = self.ref_gettables[sub_readout] = self.create_gettable(
gettable_name=f"{sub_readout}_ref",
var_type=qua.fixed,
)
diff = self.diff_gettables[sub_readout] = self.create_gettable(
gettable_name=f"{sub_readout}_diff",
var_type=qua.fixed,
)
Inside qua_measure, the chopped readout runs a QUA for_ loop, alternating between the v_chop and v_home voltage points and measuring at each step. After the loop it normalises the accumulated values and assigns them to the gettable variables. The important point is that all of this is self-contained within one qua_measure call. The ReadSequence calls it once and the entire chopped averaging protocol executes on the FPGA.
7. Writing a ReadSequence
A ReadSequence is a SubSequence that knows about signals and readout groups. Its job is to define the physical timeline of the measurement: when gates move, when readout groups fire, and when processing groups run. It does not need to know which specific sensors or signals are present; that is entirely determined by the configuration.
A ReadSequence subclass must provide __init__ and qua_sequence. All additional hooks that are available to SubSequence are available:
qua_declarequa_before_sweepqua_before_sequencequa_after_sequence.
7.1 __init__
The constructor forwards parent, name and sequence_config to the parent and performs any one-time setup. A common pattern is to collect the full list of QUA elements to be used in qua.align and qua.wait calls.
from arbok_driver import ReadSequence, SequenceBase
class MyReadSequence(ReadSequence):
def __init__(self, parent: SequenceBase, name: str, sequence_config: dict):
super().__init__(
parent=parent,
name=name,
sequence_config=sequence_config,
)
7.2 qua_sequence
This is where the physical measurement is described. The pattern for invoking a readout group is always the same: iterate over self.readout_groups[group_name] and call qua_measure() on each entry. Because every entry in a group is an AbstractReadout, this loop works regardless of how many sensors or signals are present in the configuration.
The ordering of group invocations defines the processing chain. Hardware measurement groups must fire before processing groups that consume their results.
from arbok_driver import arbok
def qua_sequence(self):
qua.align(*self.elements)
# Move gates to the reference point and take the reference measurement
arbok.ramp(
elements=self.arbok_params.gate_elements.get(),
target=self.arbok_params.v_reference,
operation='unit_ramp',
)
qua.align(*self.elements)
for _, readout in self.readout_groups['ref'].items():
readout.qua_measure()
# Move gates to the read point and take the read measurement
arbok.ramp(
elements=self.arbok_params.gate_elements.get(),
reference=self.arbok_params.v_reference,
target=self.arbok_params.v_read,
operation='unit_ramp',
)
qua.align(*self.elements)
for _, readout in self.readout_groups['read'].items():
readout.qua_measure()
# On-FPGA processing: difference then threshold
for _, readout in self.readout_groups['diff'].items():
readout.qua_measure()
for _, readout in self.readout_groups['state'].items():
readout.qua_measure()
qua.align()
# Optional feedback group
if 'set_feedback' in self.readout_groups:
for _, readout in self.readout_groups['set_feedback'].items():
readout.qua_measure()
To make a readout group optional (present only when the user includes it in the configuration) guard the iteration with a membership check.
7.3 Hooks like qua_declare and qua_after_sequence
qua_declare is called before the program body. Override it to declare QUA variables that belong to the sequence rather than to a specific readout. Always call super().qua_declare() to ensure base class declarations run.
qua_after_sequence runs at the end of each shot. Its primary use is to trigger stream saving via self.measurement.qua_check_step_requirements. You do not need to call this manually for each gettable; the framework saves all registered gettables when this hook fires.
def qua_declare(self):
return super().qua_declare()
def qua_after_sequence(self):
self.measurement.qua_check_step_requirements(self.save_variables)
def save_variables(self):
for _, readout in self.abstract_readouts.items():
readout.qua_save_variables()
8. Writing the Configuration
The configuration dictionary is the single place where readout classes, signals and parameter values are connected. Its four top-level keys are sequence, parameters, signals and readout_groups.
8.1 Anatomy of a readout group entry
Each entry in a readout group is a dictionary with the following keys.
readout_class is the AbstractReadout subclass to instantiate for this entry.
signal is the name of the signal (from the signals list) that owns the result.
kwargs is a dictionary of keyword arguments forwarded to the readout class constructor after the five mandatory arguments. Hardware element names and dotted gettable paths go here.
params or parameters (the configuration accepts both) is a dictionary of settable parameter definitions. These are only required when the readout class declares a PARAMETER_CLASS.
The key under which the entry appears in the group dictionary becomes both the name argument passed to the readout constructor and the suffix in the gettable path.
from arbok_driver.examples.readout_classes import DcAverage
# Annotated example of a single readout group entry
{
'p1p2': { # this string becomes `name` and the path suffix
'readout_class': DcAverage, # AbstractReadout subclass
'signal': 'p1p2', # must match an entry in 'signals'
'kwargs': {
'qua_element': 'SET1', # forwarded as keyword argument to __init__
},
'params': {
'some_param': {'type': Int, 'value': 1}, # only if PARAMETER_CLASS is set
},
},
}
8.2 Writing gettable paths in kwargs
When a readout consumes the result of an earlier readout, the path is supplied as a string in kwargs. The full path is:
<readsequence_name>.<signal_name>.<group_name>__<entry_key>
You need the readsequence_name because multiple ReadSequences can coexist in a Measurement. If you skip the readsequence_name only signals within the own readout sequence are being considered. The signal_name narrows the search to a specific signal. The final segment <group_name>__<entry_key> identifies the specific gettable within that signal. For DcChoppedReadout, which creates multiple gettables per entry, the suffix also includes the per-sensor suffix chosen in _create_gettables, for example chop__p1p2_diff.
from arbok_driver.examples.readout_classes import Difference, Threshold
readout_sequence_config = {
'parameters': {},
'signals': ['p1p2'],
'readout_groups': {
# A Difference entry consuming two DcAverage results
'diff': {
'p1p2': {
'readout_class': Difference,
'signal': 'p1p2',
'kwargs': {
'minuend': 'my_sequence.p1p2.ref__p1p2', # group=ref, entry=p1p2
'subtrahend': 'my_sequence.p1p2.read__p1p2', # group=read, entry=p1p2
},
},
},
# A Threshold entry consuming the Difference result
'state': {
'p1p2': {
'readout_class': Threshold,
'signal': 'p1p2',
'kwargs': {
'charge_readout': 'my_sequence.p1p2.diff__p1p2', # group=diff, entry=p1p2
},
'parameters': {
'threshold': {'type': Voltage, 'value': 0.001}
},
},
}
}
}
9. Checklist for a New Readout Class
Use this checklist when writing a new AbstractReadout subclass.
The __init__ signature starts with exactly name, read_sequence, signal, save_results, parameters followed by any custom arguments. super().__init__ is called with those five arguments. _create_gettables is the last call in __init__.
_create_gettables calls create_gettable for every result this readout produces. It calls get_gettable_from_path for every result it consumes from an earlier readout. No QUA code appears here.
qua_measure uses only qua.* calls and accesses gettable variables via .qua_result_var. It does not call create_gettable or get_gettable_from_path.
If the class needs settable parameters, a frozen ParameterClass dataclass is defined, assigned to PARAMETER_CLASS, and annotated as arbok_params. Values are accessed as self.arbok_params.<field>.qua inside qua_measure.
If the class needs temporary QUA variables, qua_declare_variables is overridden and super().qua_declare_variables() is called first.
The readout group name used in qua_sequence to invoke this class matches a key in the readout_groups section of the configuration.
Any gettable path string passed via kwargs in the configuration matches the full path <readsequence_name>.<signal_name>.<group_name>__<entry_key> of a gettable produced by an earlier group.
10. Complete Minimal Example
The following cells bring everything together as the smallest possible working example: a single hardware readout followed by a threshold classification.
# --- Minimal hardware readout class ---
from arbok_driver import AbstractReadout
from qm import qua
class DcAverage(AbstractReadout):
def __init__(
self,name, read_sequence, signal, save_results, parameters, qua_element):
super().__init__(
name=name,
read_sequence=read_sequence,
signal=signal,
save_results=save_results,
parameters=parameters
)
self.qua_element = qua_element
self._create_gettables()
def _create_gettables(self):
self.current_gettable = self.create_gettable(
gettable_name=self.name, var_type=qua.fixed)
def qua_measure(self):
qua.measure(
'measure', self.qua_element,
qua.integration.full('x_const', self.current_gettable.qua_result_var),
)
2026-04-16 01:37:20,556 - qm - INFO - Starting session: 7c17f2d0-7b2f-4790-9dd8-758496fe78e8
# --- Minimal ReadSequence ---
from arbok_driver import ReadSequence, SequenceBase
class MinimalReadout(ReadSequence):
def __init__(self, parent: SequenceBase, name: str, sequence_config: dict):
super().__init__(parent=parent, name=name, sequence_config=sequence_config)
def qua_sequence(self):
qua.align(*self.elements)
for _, readout in self.readout_groups['measure'].items():
readout.qua_measure()
for _, readout in self.readout_groups['state'].items():
readout.qua_measure()
qua.align()
def qua_after_sequence(self):
self.measurement.qua_check_step_requirements(self.save_variables)
def save_variables(self):
for _, readout in self.abstract_readouts.items():
readout.qua_save_variables()
# --- Configuration ---
from arbok_driver.parameter_types import Voltage, List
from arbok_driver.examples.readout_classes import Threshold
minimal_conf = {
'sequence': MinimalReadout,
'parameters': {
'gate_elements': {'type': List, 'value': ['P1', 'P2']},
'readout_elements': {'type': List, 'value': ['SET1']},
},
'signals': ['q1'],
'readout_groups': {
'measure': {
'q1': {
'readout_class': DcAverage,
'signal': 'q1',
'kwargs': {'qua_element': 'SET1'},
},
},
'state': {
'q1': {
'readout_class': Threshold,
'signal': 'q1',
'kwargs': {'charge_readout': 'minimal_readout.q1.measure__q1'},
'parameters': {'threshold': {'type': Voltage, 'value': 0.001}},
},
},
},
}
# --- Instantiation and inspection ---
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')
minimal_readout = MinimalReadout(
parent=mock_measurement,
name='minimal_readout',
sequence_config=minimal_conf,
)
minimal_readout.print_readable_snapshot()
rprint(mock_measurement.get_qua_program_as_str())
qm_driver_mock_measurement_minimal_readout:
parameter value
--------------------------------------------------------------------------------
gate_elements : ['P1', 'P2'] (N/A)
measure__q1 : Not available
readout_elements : ['SET1'] (N/A)
state__q1 : Not available
state__q1__threshold : 0.001 (V)
# Single QUA script generated at 2026-04-16 01:37:22.412420 # QUA library version: 1.2.5 from qm import CompilerOptionArguments from qm.qua import * with program() as prog: v1 = declare(int, value=0) v2 = declare(fixed, ) v3 = declare(bool, ) with infinite_loop_(): pause() assign(v1, 0) align("P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "P9", "P10", "P11", "P12", "J1", "J2", "J3", "J4", "J5", "J6", "J7", "J8", "J9", "J10", "J11", "SET1", "SET2", "SET3", "SET4", "SET5", "SET6", "qe1", "Q1", "Y1", "Q2", "Y2", "Q3", "Q4", "Q5", "Q6", "Q7", "Q8") measure("measure", "SET1", integration.full("x_const", v2, "")) assign(v3, (v2>0.001)) align() r2 = declare_stream() save(v2, r2) r3 = declare_stream() save(v3, r3) align() assign(v1, (v1+1)) r1 = declare_stream() save(v1, r1) align() with stream_processing(): r1.buffer(1).save("qm_driver_mock_measurement_shots") r2.buffer(1).save("qm_driver_mock_measurement_minimal_readout_measure__q1") r3.buffer(1).save("qm_driver_mock_measurement_minimal_readout_state__q1") config = None loaded_config = None