Source code for qblox_scheduler.experiments.experiment

# Repository: https://gitlab.com/qblox/packages/software/qblox-scheduler
# Licensed according to the LICENSE file on the main branch
#
# Copyright 2025, Qblox B.V.
"""Module containing the core experiment concepts."""

from __future__ import annotations

import inspect
import logging
from abc import ABC, abstractmethod
from collections import UserDict
from copy import copy, deepcopy
from enum import Enum
from typing import TYPE_CHECKING

from xarray import Dataset

from qblox_scheduler.helpers.collections import make_hash
from qblox_scheduler.helpers.importers import export_python_object_to_path_string
from qblox_scheduler.instrument_coordinator.utility import merge_acquisition_sets
from qblox_scheduler.json_utils import (
    JSONSchemaValMixin,
    JSONSerializable,
)
from qblox_scheduler.operations.expressions import DType, substitute_value_in_arbitrary_container
from qblox_scheduler.operations.variables import Variable

if TYPE_CHECKING:
    from typing import Any

    from qblox_scheduler.device_under_test import QuantumDevice
    from qblox_scheduler.operations.expressions import Expression

[docs] logger = logging.getLogger(__name__)
[docs] class Step(JSONSchemaValMixin, UserDict, ABC): """ A step containing a single (possibly) near-time operation to be performed in an experiment. An `Experiment` consists of steps, each of which performs a specific operation (usually on hardware). There is no real-time guarantee between steps, as opposed to `Operation`. """
[docs] schema_filename = "step.json"
[docs] _class_signature = None
def __init__(self, name: str) -> None: super().__init__() # ensure keys exist self.data["name"] = name self.data["schedule_info"] = {} self.data["parameter_info"] = {} self.data["hardware_option_info"] = {} self.data["hardware_description_field_info"] = {} def __eq__(self, other: object) -> bool: """ Returns the equality of two instances based on its hash. Parameters ---------- other The other operation to compare to. Returns ------- : """ return hash(self) == hash(other) def __str__(self) -> str: """ Returns a unique, evaluable string for unchanged data. Returns a concise string representation which can be evaluated into a new instance using :code:`eval(str(operation))` only when the data dictionary has not been modified. This representation is guaranteed to be unique. """ return f"{self.__class__.__name__}(name='{self.name}')" def __getstate__(self) -> dict[str, object]: return { "deserialization_type": export_python_object_to_path_string(self.__class__), "data": self.data, } def __setstate__(self, state: dict[str, dict]) -> None: self.data = state["data"] self._update() def __hash__(self) -> int: return make_hash(self.data)
[docs] def _update(self) -> None: """Update the Step's internals.""" pass
[docs] def clone(self) -> Step: """Clone this operation into a new independent operation.""" state = self.__getstate__() state["data"] = deepcopy(state["data"]) new_self = self.copy() # convincing the type checker of the accuracy of an arbitrary dict is difficult new_self.__setstate__(state) # type: ignore return new_self
[docs] def substitute( self, substitutions: dict[Expression, Expression | int | float | complex] ) -> Step: """Substitute matching expressions in operand, possibly evaluating a result.""" state = self.__getstate__() state["data"], changed = substitute_value_in_arbitrary_container( state["data"], # type: ignore substitutions, ) if changed: new_self = self.copy() # convincing the type checker of the accuracy of an arbitrary dict is difficult new_self.__setstate__(state) # type: ignore return new_self else: return self
@property
[docs] def name(self) -> str: """Return the name of the step.""" return self.data["name"]
@classmethod
[docs] def _get_signature(cls, parameters: dict) -> str: """ Returns the constructor call signature of this instance for serialization. The string constructor representation can be used to recreate the object using eval(signature). Parameters ---------- parameters : dict The current data dictionary. Returns ------- : """ if cls._class_signature is None: logger.info("Caching signature for class %s", cls.__name__) cls._class_signature = inspect.signature(cls) signature = cls._class_signature def to_kwarg(key: str) -> str: """ Returns a key-value pair in string format of a keyword argument. Parameters ---------- key The parameter key Returns ------- : """ value = parameters[key] if isinstance(value, Enum): enum_value = value.value value = enum_value value = f"'{value}'" if isinstance(value, str) else value return f"{key}={value}" required_params = list(signature.parameters.keys()) kwargs_list = map(to_kwarg, required_params) return f"{cls.__name__}({','.join(kwargs_list)})"
@abstractmethod
[docs] def run(self, device: QuantumDevice, timeout: int) -> Dataset | None: """Execute step on quantum device.""" ...
[docs] class Experiment(JSONSchemaValMixin, JSONSerializable, UserDict): """An experiment."""
[docs] schema_filename = "experiment.json"
def __init__(self, name: str, data: dict[str, Any] | None = None) -> None: super().__init__() # ensure keys exist self.data["name"] = name self.data["steps"] = [] self.data["variables"] = {} if data is not None: self.data.update(data) def __getstate__(self) -> dict[str, Any]: data = copy(self.data) return { "deserialization_type": export_python_object_to_path_string(self.__class__), "data": data, } def __setstate__(self, state: dict[str, Any]) -> None: self.data = state["data"] @property
[docs] def name(self) -> str: """Return the name of the experiment.""" return self.data["name"]
@property
[docs] def steps(self) -> list[Step]: """Return the steps in the experiment.""" return self.data["steps"]
[docs] def declare(self, dtype: DType) -> Variable: """ Declare a variable. Parameters ---------- dtype The variable type. """ var = Variable(dtype) self.define(var) return var
[docs] def define(self, var: Variable) -> None: """ Add a declared variable. Parameters ---------- var The variable. """ self.data["variables"][var.id_] = var
[docs] def add(self, step: Step) -> None: """Add step to experiment.""" self.steps.append(step)
[docs] def run( self, device: QuantumDevice, timeout: int = 10, ) -> Dataset: """Run experiment on quantum device.""" # Steps may modify the configuration. This should not be persistent so we copy the state. device = device.model_copy(deep=True) data_set = Dataset() for step in self.steps: step_data_set = step.run(device, timeout=timeout) if step_data_set is not None: data_set = merge_acquisition_sets(data_set, step_data_set) return data_set
[docs] def clone(self) -> Experiment: """Clone this schedule into a separate independent experiment.""" new_data = deepcopy(self.data) return self.__class__(self.name, new_data)
[docs] def substitute( self, substitutions: dict[Expression, Expression | int | float | complex] ) -> Experiment: """Substitute matching expressions in this experiment.""" changed = False new_steps = [] for step in self.steps: new_step = step.substitute(substitutions) if new_step is not step: changed = True else: new_step = step.clone() new_steps.append(new_step) if changed: new_experiment = self.clone() new_experiment.data["steps"] = new_steps return new_experiment else: return self