Source code for qblox_scheduler.device_under_test.quantum_device

# Repository: https://gitlab.com/qblox/packages/software/qblox-scheduler
# Licensed according to the LICENSE file on the main branch
#
# Copyright 2020-2025, Quantify Consortium
# Copyright 2025, Qblox B.V.
"""Module containing the QuantumDevice object."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, TypeVar
from typing_extensions import Self

from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    ModelWrapValidatorHandler,
    PositiveInt,
    TypeAdapter,
    field_validator,
    model_validator,
)
from qcodes.instrument import Instrument, find_or_create_instrument

from qblox_scheduler.backends.graph_compilation import (
    DeviceCompilationConfig,
    SerialCompilationConfig,
)
from qblox_scheduler.backends.qblox.exceptions import InvalidQuantumDeviceConfigurationError
from qblox_scheduler.backends.qblox_backend import QbloxHardwareCompilationConfig
from qblox_scheduler.backends.types.common import HardwareCompilationConfig
from qblox_scheduler.device_under_test.device_element import DeviceElement
from qblox_scheduler.device_under_test.edge import Edge
from qblox_scheduler.enums import SchedulingStrategy
from qblox_scheduler.helpers.importers import (
    import_python_object_from_string,
)
from qblox_scheduler.instrument_coordinator import InstrumentCoordinator
from qblox_scheduler.structure.model import SchedulerBaseModel

if TYPE_CHECKING:
    from pathlib import Path

[docs] ConcreteDeviceElement = TypeVar("ConcreteDeviceElement", bound=DeviceElement)
[docs] ConcreteEdge = TypeVar("ConcreteEdge", bound=Edge)
[docs] class QuantumDevice(SchedulerBaseModel): """ The QuantumDevice directly represents the device under test (DUT). This contains a description of the connectivity to the control hardware as well as parameters specifying quantities like cross talk, attenuation and calibrated cable-delays. The QuantumDevice also contains references to individual DeviceElements, representations of elements on a device (e.g, a transmon qubit) containing the (calibrated) control-pulse parameters. This object can be used to generate configuration files for the compilation step from the gate-level to the pulse level description. These configuration files should be compatible with the :meth:`~qblox_scheduler.backends.graph_compilation.ScheduleCompiler.compile` function. """
[docs] model_config = ConfigDict( arbitrary_types_allowed=True, # to accept InstrumentCoordinator )
[docs] elements: dict[str, ConcreteDeviceElement] = Field( # type: ignore[reportGeneralTypeIssues] default_factory=dict, description="A list containing all elements that are located on this QuantumDevice.", )
[docs] edges: dict[str, ConcreteEdge] = Field( # type: ignore[reportGeneralTypeIssues] default_factory=dict, description=( "A list containing all the edges which connect the DeviceElements " "within this QuantumDevice." ), )
[docs] instr_instrument_coordinator: InstrumentCoordinator | None = Field( default=None, description="A reference to the instrument_coordinator instrument.", exclude=True, )
[docs] cfg_sched_repetitions: PositiveInt = Field( default=1024, description=( "The number of times execution of the schedule gets repeated when " "performing experiments, i.e. used to set the repetitions attribute of " "the TimeableSchedule objects generated." ), )
[docs] keep_original_schedule: bool = Field( default=True, description=( "If `True`, the compiler will not modify the schedule argument. " "If `False`, the compilation modifies the schedule, thereby " "making the original schedule unusable for further usage; this " "improves compilation time. Warning: if `False`, the returned schedule " "references objects from the original schedule, please refrain from modifying " "the original schedule after compilation in this case!" ), )
if TYPE_CHECKING: # This is needed to address the fact that we often assign a dict to `hardware_config` # which is automatically deserialized by Pydantic into the proper `HardwareConfig` # at runtime; however, pyright doesn't know this and complains that the attribute # cannot be a dict. # We don't *actually* want dicts to be allowed as values, hence the branching # with TYPE_CHECKING to appease the type checker.
[docs] hardware_config: HardwareCompilationConfig | dict | None = None
else: hardware_config: QbloxHardwareCompilationConfig | None = Field( default=None, discriminator="config_type", ) """ The input dictionary used to generate a valid HardwareCompilationConfig using :meth:`~.generate_hardware_compilation_config`. This configures the compilation from the quantum-device layer to the control-hardware layer. Useful methods to write and reload the configuration from a json file are :meth:`~.HardwareConfig.load_from_json_file` and :meth:`~.HardwareConfig.write_to_json_file`. """
[docs] scheduling_strategy: SchedulingStrategy = Field( default=SchedulingStrategy.ASAP, description="Scheduling strategy used to calculate absolute timing.", )
def __getstate__(self) -> dict[str, Any]: """ Get the state of :class:`~QuantumDevice` (used for YAML serialization). We need to skip `instr_instrument_coordinator`. """ return { field_name: field_value for field_name, field_value in iter(self) if field_name != "instr_instrument_coordinator" } def __deepcopy__(self, memo: dict[int, Any] | None = None) -> QuantumDevice: """Override deepcopy to not copy instr_instrument_coordinator.""" instr_coord = self.instr_instrument_coordinator self.instr_instrument_coordinator = None deepcopied = super().__deepcopy__(memo) deepcopied.instr_instrument_coordinator = instr_coord self.instr_instrument_coordinator = instr_coord return deepcopied @field_validator("instr_instrument_coordinator", mode="before") @classmethod
[docs] def validate_instrument_coordinator( cls, value: str | InstrumentCoordinator | None ) -> Instrument | None: """ Load InstrumentCoordinator instance from its name. Pydantic doesn't know how to handle a QCoDeS instrument; thus, we have to allow arbitrary types and manually fetch them with `find_or_create_instrument`. """ match value: case str(): return find_or_create_instrument(InstrumentCoordinator, "ic") case InstrumentCoordinator(): return value case None: return None raise ValueError("expected an InstrumentCoordinator instance or its name.")
@field_validator("scheduling_strategy") @classmethod
[docs] def validate_scheduling_strategy(cls, value: str | SchedulingStrategy) -> SchedulingStrategy: """Force `scheduling_strategy` into its proper enum value.""" return SchedulingStrategy(value)
@model_validator(mode="wrap") @classmethod
[docs] def validate_elements_and_edges( cls, data: Any, # noqa: ANN401 handler: ModelWrapValidatorHandler[Self], ) -> Self: """ Add elements and edges to the model by calling `add_element` and `add_edge` respectively to force our consistency checks. """ if isinstance(data, cls): return handler(data) elements = data.pop("elements", {}) edges = data.pop("edges", {}) # Invoking the handler will cause this method to be called again, but it seems like # it's expected behaviour with wrap model validators. The nested call won't receive # any edges, and thus the for loop below will be skipped. model = handler(data) ta_element = TypeAdapter(ConcreteDeviceElement) ta_edge = TypeAdapter(ConcreteEdge) for element_data in elements.values(): element = ta_element.validate_python(element_data) model.add_element(element) for edge_data in edges.values(): edge = ta_edge.validate_python(edge_data) model.add_edge(edge) return model
[docs] def generate_compilation_config(self) -> SerialCompilationConfig: """Generate a config for use with a :class:`~.graph_compilation.ScheduleCompiler`.""" return SerialCompilationConfig( name="QuantumDevice-generated SerialCompilationConfig", keep_original_schedule=self.keep_original_schedule, device_compilation_config=self.generate_device_config(), hardware_compilation_config=self.generate_hardware_compilation_config(), )
[docs] def generate_hardware_config(self) -> dict[str, Any]: """ Generate a valid hardware configuration describing the quantum device. Returns ------- The hardware configuration file used for compiling from the quantum-device layer to a hardware backend. .. warning: The config currently has to be specified by the user using the :code:`hardware_config` parameter. """ return ( # Exclude `compilation_passes` and other fields bloating the output self.hardware_config.model_dump(exclude_unset=True) if isinstance(self.hardware_config, BaseModel) else {} )
[docs] def generate_device_config(self) -> DeviceCompilationConfig: """ Generate a device config. This config is used to compile from the quantum-circuit to the quantum-device layer. """ clocks = {} elements_cfg = {} edges_cfg = {} # iterate over the elements on the device for element in self.elements.values(): element_cfg = element.generate_device_config() clocks.update(element_cfg.clocks) elements_cfg.update(element_cfg.elements) # iterate over the edges on the device for edge in self.edges.values(): edge_cfg = edge.generate_edge_config() edges_cfg.update(edge_cfg) # Ignore pyright because of a bug (the error is in the DeviceCompilationConfig class) device_config = DeviceCompilationConfig( # type: ignore elements=elements_cfg, clocks=clocks, edges=edges_cfg, scheduling_strategy=self.scheduling_strategy, ) return device_config
[docs] def generate_hardware_compilation_config(self) -> HardwareCompilationConfig | None: """ Generate a hardware compilation config. The compilation config is used to compile from the quantum-device to the control-hardware layer. """ hardware_config = self.hardware_config if hardware_config is None: return None elif isinstance(hardware_config, HardwareCompilationConfig): # Hardware config is already a valid HardwareCompilationConfig DataStructure return hardware_config else: # Parse a (backend-specific) HardwareCompilationConfig if "backend" in hardware_config: raise ValueError( f"`{HardwareCompilationConfig.__name__}` no longer takes a" f" 'backend' field; instead, specify the 'config_type', which should" " contain a string reference to the backend-specific datastructure" " that should be parsed." ) hardware_compilation_config_model = hardware_config["config_type"] if isinstance(hardware_compilation_config_model, str): hardware_compilation_config_model = import_python_object_from_string( hardware_compilation_config_model ) hardware_compilation_config = hardware_compilation_config_model.model_validate( hardware_config ) return hardware_compilation_config
[docs] def get_element(self, name: str) -> DeviceElement: """ Return a :class:`~qblox_scheduler.device_under_test.device_element.DeviceElement` by name. Parameters ---------- name The element name. Returns ------- : The element. Raises ------ KeyError If key ``name`` is not present in `self.elements`. """ try: return self.elements[name] except KeyError: raise KeyError(f"'{name}' is not an element of {self.name}.") from None
[docs] def add_element( self, element: DeviceElement, ) -> None: """ Add an element to the elements collection. Parameters ---------- element The element to add. Raises ------ ValueError If an element with a duplicated name is added to the collection. TypeError If :code:`element` is not an instance of the base element. """ if not isinstance(element, DeviceElement): raise TypeError(f"{element!r} is not a DeviceElement.") if element.name in self.elements: raise ValueError(f"'{element.name}' has already been added.") self.elements[element.name] = element
[docs] def remove_element(self, name: str) -> None: """ Removes an element by name. Parameters ---------- name The element name. Has to follow the convention ``"{element_0}_{element_1}"``. """ try: del self.elements[name] except KeyError: raise KeyError(f"'{name}' is not an element of {self.name}.") from None
[docs] def get_edge(self, name: str) -> Edge: """ Returns an edge by name. Parameters ---------- name The edge name. Has to follow the convention ``"{element_0}_{element_1}"``. Returns ------- : The edge. Raises ------ KeyError If key ``name`` is not present in ``self.edges``. """ try: return self.edges[name] except KeyError: raise KeyError(f"'{name}' is not an edge of {self.name}.") from None
[docs] def add_edge(self, edge: Edge) -> None: """ Add the edges. Parameters ---------- edge The edge to add. """ if not isinstance(edge, Edge): raise TypeError(f"{edge!r} is not an Edge") if edge.name in self.edges: raise ValueError(f"'{edge.name}' has already been added") for element_name in (edge.parent_element_name, edge.child_element_name): if element_name not in self.elements: raise ValueError(f"'{element_name}' is not an element of {self.name}.") for bound_element in (edge.parent_element, edge.child_element): if bound_element is not None and id(bound_element) != id( self.elements[bound_element.name] ): raise ValueError( f"A different element with name {bound_element.name} " f"is already present in {self.name}." ) if edge.parent_element is None: edge._parent_device_element = self.elements[edge.parent_element_name] if edge.child_element is None: edge._child_device_element = self.elements[edge.child_element_name] self.edges[edge.name] = edge
[docs] def remove_edge(self, name: str) -> None: """ Remove an edge by name. Parameters ---------- name The edge name connecting the elements. Has to follow the convention ``"{element_0}_{element_1}"``. """ try: del self.edges[name] except KeyError: raise KeyError(f"'{name}' is not an edge of {self.name}.") from None
@classmethod
[docs] def from_json_file(cls, filename: str | Path) -> Self: """Read JSON data from a file and convert it to an instance of the attached class.""" with open(filename) as file: text = file.read() # the json format of QuantumDevice has changed from quantify to qblox, where before # we had e.g. the line # "deserialization_type": "quantify_scheduler.device_under_test... # the current format does not have this key. if "deserialization_type" in text: raise InvalidQuantumDeviceConfigurationError( "If passing an old quantify_scheduler configuration, " "try converting it to a qblox_scheduler configuration using \n" "our migration helper tool after installing qblox-scheduler[cli]:\n" f" qblox-scheduler convert-configs {filename}\n" ) return cls.from_json(text)