Skip to content

Integrating a measurement

Running a pre-defined measurement from the Qruise Experiment Catalogue is already covered by our getting started guide. In this user guide, we will explain how to contribute a new measurement to your own custom measurement catalogue.

The goal is to contribute to a yourlab.measurements module from which you can retrieve and run a measurement using the following lines of code:

from utils import get_measurement_backend
import yourlab.measurements as measurements

qpu = get_measurement_backend()
QUBIT_NAME = "Q4"
Measurement = measurements.NewCustomMeasurement
data = Measurement(qpu,QUBIT_NAME).measure()

We now delve into the details of how to enable the pattern above by explaining:

  • the yourlab.measurements module structure
  • how to integrate your QPU specific code by leveraging QruiseOS templates

Tutorial 05_integrating_a_new_backend

If you prefer a hands-on approach, go through this tutorial in your Jupyter instance. It illustrates both how to integrate a new QPU backend (this part can be ignored) and how to integrate a new measurement. The point is to show how an isolated and unversioned code that a typical user would have on their machine can be integrated into a class that other colleagues can use and contribute to.

Module structure

We suggest to structure the optionally private yourlab.measurements codebase in the following way:

yourlab/
└── measurements/
    ├── __init__.py
    ├── common.py
    ├── _t1.py
    ├── ...
    ├── _resonator_spectroscopy.py
    └── _new_custom_measurement.py

The most important bit is that for clarity and ease of maintenance we recommend that each _<measurement>.py file holds the definition of a single measurement class. For example, _t1.py and _resonator_spectroscopy would respectively hold T1Measurement and ResonatorSpectroscopyMeasurement. Besides, we configure __init__.py to ease the import statements.

Easing import with __init__.py

We suggest to use explicit export by specifying the __all__ attribute. When you define __all__ in a module, you are explicitly stating which names are public and intended to be accessible to the users of the module. It is a convenient way to have control over the API of the module by restricting the list of options when using TAB completion like measurements.<TAB>. In practice, the __init.py__ file should define an __all__ list of names and would look like this:

__init__.py
from ._t1 import T1Measurement
#...
from ._resonator_spectroscopy import ResonatorSpectroscopyMeasurement
from ._new_custom_measurement import NewCustomMeasurement

    __all__ = [
    "T1Measurement",
    #...
    "ResonatorSpectroscopyMeasurement",
    "NewCustomMeasurement"]

The purpose of the common.py file is to improve readability and factor out boilerplate (repetitive) code. You can read more on this in the final section of this guide.

Integrate your QPU specific code

Looking at the following extract of code shows several things:

Measurement = measurements.NewCustomMeasurement
data = Measurement(qpu,QUBIT_NAME).measure()
  • Measurement is a Python class
  • you instantiate Measurement by providing relevant configuration like a QPU backend and the qubits involved in the measurement
  • it has a measure() method to actually run the measurement

Note

For this concise code to work, the context, configuration, and QPU logic must (for the most part) be defined behind the scene or optionally through passing arguments. This is intentional to provide a harmonized way to interact with measurements and ease the maintenance of the catalogue of measurements.

We now describe a template for a NewCustomMeasurement class that replicates the same pattern. The convention we adopt is to consider strings like <description> as placeholders for code.

Notice that though this piece of code can look a bit overwhelming at first, we need first to focus on the two mandatory place holders:

  • The qpu_program method is here to integrate a user defined QPU that achieves the desired measurement.

  • The data_format method which holds the logic of formatting and structuring data. Indeed, Qruise's experience is that consolidating the measurement configuration, like 1D or 2D sweeps and shots with raw measurements, into a dataset eases the analysis and user's life down the line.

_new_custom_measurement.py
import xarray as xr
from qruise.experiment.protocols import BackendQubit
from .common import YourMeasurement1Q, YourMeasurementBackend


class NewCustomMeasurement(YourMeasurement1Q):

    def __init__(
        self,
        measurement_backend: YourMeasurementBackend,
        qubit: BackendQubit,
        **config_kwargs
    ):
        super().__init__(measurement_backend, qubit)

        self.config = self.qpu_config(**config_kwargs)

    def qpu_config(self, **config_kwargs):
        config = self.get_config_update(**config_kwargs)
        return config

    def qpu_program(self, **measure_kwargs):

        # <qpu specific operations that describes the measurement>

        return program

    def data_format(self, data, **measure_kwargs) -> xr.Dataset:

        # <data formatting of raw measures>

        return ds

    def measure(self,**measure_kwargs) -> xr.Dataset:

        program = self.qpu_program(**measure_kwargs)
        result = self.run_job(
            program=program,
            config=self.config,
            **measure_kwargs
        )

        ds = self.data_format(result,**measure_kwargs)
        return ds

The two other methods qpu_config and measure rely on methods of the YourMeasurement1Q that are common to all measurements. It is useful to incorporate these by default methods in the NewCustomMeasurement class for flexibility.

Standard flow versus flexibility tradeoff

Note that this template proposes a clear process model (qpu_config, qpu_program, data_format, measure) while also allowing for great flexibility. Indeed, nothing prevents the user from adding methods for convenience, changing the standard flow in the measure method or tweaking the qpu_config code. This is definitely handy for experimentalists when configuring and setting up a new experiment. Indeed, at this particular moment, the experimentalist needs maximal flexibility and needs easy access to all the available knobs or easy way to insert intermediate processing and tests within the overall logic of the measurement.

What about multiple qubit measurements?

Note that the template above corresponds to a single qubit measurement. Of course QruiseOS provides very similar templates for multiple qubit measurements. The main difference would be:

  • a different import that would be from .common import YourMeasurement2Q for two qubits
  • a different __init__ signature where multiple qubits need to be specified

The architecture choices explained

We should stress here that this topic is more advanced and that coming up with the common.py file is mostly a "one for all" task. This work has to be done when enabling QPU access through QruiseOS, i.e. when starting to integrate hardware and software together with the help of Qruise.

The common.py file is used twice in the template just above:

  • once to import YourMeasurementBackend (we refer the interested reader to tutorial 05_integrating_a_new_backend for more details about that).
  • once to import YourMeasurement1Q, which is a master class of NewCustomerMeasurement.

The reason to make NewCustomMeasurement a subclass of YourMeasurement1Q is to factor out the boilerplate code that is shared among all measurements. This is made clearer with an example template for common.py:

common.py
from abc import ABC, abstractmethod
from typing import Any, Dict
from qruise.experiment.measurement import MeasurementBase
from qruise.experiment.protocols import BackendQubit

class YourMeasurementBackend:
# <code to handle specific details about your QPU access and configuration>

class YourMeasurement1Q(MeasurementBase, ABC):
    """Base class for 1 qubit measurements."""

    def __init__(
        self,
        measurement_backend: YourMeasurementBackend,
        qubit: BackendQubit,
    ):
        super().__init__(measurement_backend)
        self.qubit = qubit

    def get_config_update(self, **kwargs) -> Dict:
        """Get a configuration updated from session."""
        return self.measurement_backend.get_config_update(**kwargs)

    def run_job(self, program: Any, config: Dict):
        """Run a job."""
        return self.measurement_backend.run_job(
            program=program,
            config=config,
        )

    @abstractmethod
    def qpu_config(self, *args, **kwargs):
        pass

    @abstractmethod
    def qpu_program(self, *args, **kwargs):
        pass

    # <code for methods or attributes that could conveniently
    # be shared among measurements>

Notice in the code above that YourMeasurement1Q has get_config_update and run_job methods that will generally be called and used as is by a custom measurement class (see _new_custom_measurement.py template above).

Besides it has methods decorated with @abstractmethod to enforce the implementation of some method naming and signatures. This helps in keeping all measurements within a single catalogue consistent. This feature is coming from the ABC class.

Purpose of ABC class

The purpose of the ABC class in Python is to serve as a base class for defining Abstract Base Classes. An Abstract Base Class is a class that cannot be instantiated on its own and is meant to be subclassed by other classes. It can include abstract methods (through the use of @abstractmethod decorator) that must be implemented by any subclass.

This ensures that all subclasses follow a certain structure and implement required behaviors.