Parameters#

A parameter is a named handle that an operation reads from or writes to. On the surface it looks like a single getter/setter pair:

qubit_frequency()        # read
qubit_frequency(5.2e9)   # write

Underneath, it’s an abstraction layer that solves two problems an operation should not have to think about: where the value lives between Python processes (running a protocol on a notebook first and then on a script for example), and how each hardware platform actually programs it.

Why parameters?#

Persistence across processes#

Lab work runs in many processes — a notebook for ad-hoc operations, a script for a full protocol, a dashboard for live monitoring. They all need to see the same parameter values. Parameters do not store values in themselves; they hold a params proxy to whatever persistence backend the user wants. The common choice today we use is the parameter manager from instrumentserver, but a config file or any other store works equally well — the labcore-side API does not change.

Hardware translation#

Different platforms speak different languages. A QICK FPGA can program a qubit frequency in GHz directly. An OPX has to split the same value into an intermediate frequency and a local-oscillator frequency, then mix them. Each platform-specific getter/setter on the parameter holds whatever conversion logic that platform needs. Operations never see this — they just call qubit_frequency() and get the actual frequency back.

The shape of a parameter#

A parameter is a dataclass subclass of ProtocolParameterBase with three fields and one platform-specific getter/setter pair per backend:

Field

What it is

name

The parameter’s display name. Used in reports and logs.

description

Plain-English description of the value.

params

The hardware/persistence handle. None on DUMMY; on real hardware it’s typically an instrumentserver parameter-manager proxy.

The class implements _dummy_getter / _dummy_setter, _qick_getter / _qick_setter, and _opx_getter / _opx_setter. The right pair is dispatched inside __call__ based on which platform was selected with select_platform.

Writing a parameter#

Suppose your toolbox stores qubit frequencies in an instrumentserver parameter manager exposed as params.qubit.f(). Here is what a QubitFrequency parameter looks like:

from dataclasses import dataclass, field
from labcore.protocols import ProtocolParameterBase


@dataclass
class QubitFrequency(ProtocolParameterBase):
    name: str = field(default="qubit_frequency", init=False)
    description: str = field(
        default="Intermediate frequency of the qubit", init=False,
    )

    def _dummy_getter(self):
        return self.params.qubit.f()

    def _dummy_setter(self, value):
        self.params.qubit.f(value)

    def _qick_getter(self):
        return self.params.qubit.freq()

    def _qick_setter(self, value):
        self.params.qubit.freq(value)

The name and description fields are declared with init=False so the caller does not have to repeat them — every QubitFrequency instance has the same identity. Only params (the hardware handle) is supplied at construction time:

from labcore.protocols import select_platform

select_platform("QICK")
freq = QubitFrequency(params=my_instrument_server_proxy)

freq()           # → 5.2e9   (reads via _qick_getter)
freq(5.21e9)     # writes via _qick_setter

Note

This example writes the same value through both DUMMY and QICK because the QICK takes a frequency in GHz directly. An OPX getter/setter would do more work: it would split the requested frequency into IF + LO, write the LO to the microwave source, and write the IF to the OPX channel. That conversion is exactly the kind of platform-specific logic the parameter abstraction is there to hold.

You only implement the platforms you use#

The base class raises NotImplementedError for every platform, so a parameter only needs to implement the platforms it will actually run on. A parameter can support DUMMY and QICK only; or QICK only; or even DUMMY only for things that have no hardware analogue (a pure configuration knob, say). Calling a parameter under an unimplemented platform raises immediately, which surfaces missing support fast rather than silently falling through.

This is the common pattern in real toolboxes — see for example SaturationSpecDriveGain in CQEDToolbox, which is QICK-only.

Reusing a parameter across operations#

A parameter class is defined once and instantiated wherever it is needed. The same QubitFrequency shows up as an input to a spectroscopy operation and an output of a Rabi calibration:

class ResonatorSpectroscopy(ProtocolOperation):
    def __init__(self, params):
        super().__init__()
        self._register_inputs(qubit_frequency=QubitFrequency(params))
        # ...

class PiSpectroscopy(ProtocolOperation):
    def __init__(self, params):
        super().__init__()
        self._register_outputs(qubit_frequency=QubitFrequency(params))
        # ...

Because both instances point at the same persistence backend through params, a write performed by PiSpectroscopy is visible to every later operation that reads QubitFrequency. See Operations for the _register_inputs / _register_outputs API.

Real-world parameters: instrumentserver-backed#

Real toolbox parameters are usually a little more elaborate than the example above. The instrumentserver helper nestedAttributeFromString lets the getter/setter resolve a dotted attribute path on the proxy, which is convenient when the parameter manager organizes values under a per-qubit subtree:

from instrumentserver.helpers import nestedAttributeFromString


@dataclass
class QubitFrequency(ProtocolParameterBase):
    name: str = field(default="qubit_frequency", init=False)
    description: str = field(default="Intermediate frequency of the qubit", init=False)

    def _qick_getter(self):
        active_qubit = nestedAttributeFromString(self.params, "active.qubit")()
        return nestedAttributeFromString(self.params, f"{active_qubit}.qubit.freq")()

    def _qick_setter(self, value):
        active_qubit = nestedAttributeFromString(self.params, "active.qubit")()
        nestedAttributeFromString(self.params, f"{active_qubit}.qubit.freq")(value)

The labcore-side API has not changed — the operation still just calls qubit_frequency() — but the getter now resolves an “active qubit” indirection and looks up a per-qubit attribute path. For a full catalogue of this style of parameter, see CQEDToolbox/protocols/parameters.py. That toolbox is a working real-world example built on labcore but is not itself documented yet.

Correction parameters#

Some parameters control a correction strategy rather than hardware state — for example, a noise tolerance threshold or the number of frequency windows to scan through. These are declared as CorrectionParameter subclasses instead. Apart from that, they look identical to a regular parameter:

from labcore.protocols import CorrectionParameter


@dataclass
class GaussianNoiseReductionFactor(CorrectionParameter):
    name: str = field(default="gaussian_noise_reduction_factor", init=False)
    description: str = field(
        default="Factor by which the measurement noise std is divided each correction step",
        init=False,
    )

    def _dummy_getter(self):
        return self._value           # in-memory storage, no hardware

    def _dummy_setter(self, v):
        self._value = v

Operations register correction parameters via _register_correction_params; they are excluded from the protocol’s pre-execution hardware-parameter verification because there is no hardware to verify against. See Operations for how corrections use these parameters.