import inspect
import json
import time
from copy import deepcopy
from dataclasses import asdict, dataclass, fields
from functools import wraps
from pathlib import Path
from typing import Callable, Generic, NewType, Optional, TypeVar, Union
import numpy as np
import numpy.typing as npt
from qibolab import AcquisitionType, AveragingMode, ExecutionParameters
from qibolab.platform import Platform
from qibolab.qubits import Qubit, QubitId, QubitPair, QubitPairId
from qibocal.config import log
from .serialize import deserialize, load, serialize
OperationId = NewType("OperationId", str)
"""Identifier for a calibration routine."""
ParameterValue = Union[float, int]
"""Valid value for a routine and runcard parameter."""
Qubits = dict[QubitId, Qubit]
"""Convenient way of passing qubit pairs in the routines."""
QubitsPairs = dict[tuple[QubitId, QubitId], QubitPair]
DATAFILE = "data"
"""Name of the file where data are dumped."""
RESULTSFILE = "results"
"""Name of the file where results are dumped."""
[docs]def show_logs(func):
"""Decorator to add logs."""
@wraps(func)
# necessary to maintain the function signature
def wrapper(*args, **kwds):
start = time.perf_counter()
out = func(*args, **kwds)
end = time.perf_counter()
if end - start < 1:
message = " in less than 1 second."
else:
message = f" in {end-start:.2f} seconds"
log.info(f"Finished {func.__name__[1:]}" + message)
return out, end - start
return wrapper
DEFAULT_PARENT_PARAMETERS = {
"nshots": None,
"relaxation_time": None,
}
"""Default values of the parameters of `Parameters`"""
[docs]class Parameters:
"""Generic action parameters.
Implement parameters as Algebraic Data Types (similar to), by
subclassing this marker in actual parameters specification for each
calibration routine.
The actual parameters structure is only used inside the routines
themselves.
"""
nshots: int
"""Number of executions on hardware."""
relaxation_time: float
"""Wait time for the qubit to decohere back to the `gnd` state."""
hardware_average: bool = False
"""By default hardware average will be performed."""
classify: bool = False
"""By default qubit state classification will not be performed."""
@classmethod
def load(cls, input_parameters):
"""Load parameters from runcard.
Possibly looking into previous steps outputs.
Parameters defined in Parameters class are removed from `parameters`
before `cls` is created.
Then `nshots` and `relaxation_time` are assigned to cls.
.. todo::
move the implementation to History, since it is required to resolve
the linked outputs
"""
default_parent_parameters = deepcopy(DEFAULT_PARENT_PARAMETERS)
parameters = deepcopy(input_parameters)
for parameter, value in default_parent_parameters.items():
default_parent_parameters[parameter] = parameters.pop(parameter, value)
instantiated_class = cls(**parameters)
for parameter, value in default_parent_parameters.items():
setattr(instantiated_class, parameter, value)
return instantiated_class
@property
def execution_parameters(self):
"""Default execution parameters."""
averaging_mode = (
AveragingMode.CYCLIC if self.hardware_average else AveragingMode.SINGLESHOT
)
acquisition_type = (
AcquisitionType.DISCRIMINATION
if self.classify
else AcquisitionType.INTEGRATION
)
return ExecutionParameters(
nshots=self.nshots,
relaxation_time=self.relaxation_time,
acquisition_type=acquisition_type,
averaging_mode=averaging_mode,
)
[docs]class AbstractData:
"""Abstract data class."""
def __init__(
self, data: dict[Union[tuple[QubitId, int], QubitId], npt.NDArray] = None
):
self.data = data if data is not None else {}
def __getitem__(self, qubit: Union[QubitId, tuple[QubitId, int]]):
"""Access data attribute member."""
if isinstance(qubit, list):
qubit = tuple(qubit)
return self.data[qubit]
@property
def params(self) -> dict:
"""Convert non-arrays attributes into dict."""
global_dict = asdict(self)
if hasattr(self, "data"):
global_dict.pop("data")
return global_dict
[docs] def save(self, path: Path, filename: str):
"""Dump class to file."""
self._to_json(path, filename)
self._to_npz(path, filename)
[docs] def _to_npz(self, path: Path, filename: str):
"""Helper function to use np.savez while converting keys into
strings."""
if hasattr(self, "data"):
np.savez(
path / f"{filename}.npz",
**{json.dumps(i): self.data[i] for i in self.data},
)
[docs] def _to_json(self, path: Path, filename: str):
"""Helper function to dump to json."""
if self.params:
(path / f"{filename}.json").write_text(
json.dumps(serialize(self.params), indent=4)
)
@classmethod
def load(cls, path: Path, filename: str):
"""Generic load method."""
data_dict = cls.load_data(path, filename)
params = cls.load_params(path, filename)
if data_dict is not None:
if params is not None:
return cls(data=data_dict, **params)
else:
return cls(data=data_dict)
elif params is not None:
return cls(**params)
[docs] @staticmethod
def load_data(path: Path, filename: str):
"""Load data stored in a npz file."""
file = path / f"{filename}.npz"
if file.is_file():
raw_data_dict = dict(np.load(file))
data_dict = {}
for data_key, array in raw_data_dict.items():
data_dict[load(data_key)] = np.rec.array(array)
return data_dict
[docs] @staticmethod
def load_params(path: Path, filename: str):
"""Load parameters stored in a json file."""
file = path / f"{filename}.json"
if file.is_file():
params = json.loads(file.read_text())
params = deserialize(params)
return params
[docs]class Data(AbstractData):
"""Data resulting from acquisition routine."""
@property
def qubits(self):
"""Access qubits from data structure."""
if set(map(type, self.data)) == {tuple}:
return list({q[0] for q in self.data})
return [q for q in self.data]
@property
def pairs(self):
"""Access qubit pairs ordered alphanumerically from data structure."""
return list({tuple(sorted(q[:2])) for q in self.data})
[docs] def register_qubit(self, dtype, data_keys, data_dict):
"""Store output for single qubit.
Args:
data_keys (tuple): Keys of Data.data.
data_dict (dict): The keys are the fields of `dtype` and
the values are the related arrays.
"""
# to be able to handle the non-sweeper case
ar = np.empty(np.shape(data_dict[list(data_dict)[0]]), dtype=dtype)
for key, value in data_dict.items():
ar[key] = value
if data_keys in self.data:
self.data[data_keys] = np.rec.array(
np.concatenate((self.data[data_keys], ar))
)
else:
self.data[data_keys] = np.rec.array(ar)
[docs] def save(self, path: Path):
"""Store data to file."""
super()._to_json(path, DATAFILE)
super()._to_npz(path, DATAFILE)
@classmethod
def load(cls, path: Path):
"""Load data and parameters."""
return super().load(path, filename=DATAFILE)
[docs]@dataclass
class Results(AbstractData):
"""Generic runcard update."""
def __contains__(self, key: Union[QubitId, QubitPairId, tuple[QubitId, ...]]):
"""Checking if qubit is in Results.
If key is not present means that fitting failed or was not
performed.
"""
if isinstance(key, list):
key = tuple(key)
return all(
key in getattr(self, field.name)
for field in fields(self)
if isinstance(getattr(self, field.name), dict)
)
@classmethod
def load(cls, path: Path):
"""Load results."""
return super().load(path, filename=RESULTSFILE)
[docs] def save(self, path: Path):
"""Store results to file."""
super()._to_json(path, RESULTSFILE)
super()._to_npz(path, RESULTSFILE)
# Internal types, in particular `_ParametersT` is used to address function
# contravariance on parameter type
_ParametersT = TypeVar("_ParametersT", bound=Parameters, contravariant=True)
_DataT = TypeVar("_DataT", bound=Data)
_ResultsT = TypeVar("_ResultsT", bound=Results)
[docs]@dataclass
class Routine(Generic[_ParametersT, _DataT, _ResultsT]):
"""A wrapped calibration routine."""
acquisition: Callable[[_ParametersT], _DataT]
"""Data acquisition function."""
fit: Callable[[_DataT], _ResultsT] = None
"""Post-processing function."""
report: Callable[[_DataT, _ResultsT], None] = None
"""Plotting function."""
update: Callable[[_ResultsT, Platform], None] = None
"""Update function platform."""
two_qubit_gates: Optional[bool] = False
"""Flag to determine whether to allocate list of Qubits or Pairs."""
def __post_init__(self):
# add decorator to show logs
self.acquisition = show_logs(self.acquisition)
self.fit = show_logs(self.fit)
if self.update is None:
self.update = _dummy_update
@property
def parameters_type(self):
"""Input parameters type."""
sig = inspect.signature(self.acquisition)
param = next(iter(sig.parameters.values()))
return param.annotation
@property
def data_type(self):
"""Data object type return by data acquisition."""
return inspect.signature(self.acquisition).return_annotation
@property
def results_type(self):
"""Results object type returned by data acquisition."""
return inspect.signature(self.fit).return_annotation
# TODO: I don't like these properties but it seems to work
@property
def platform_dependent(self):
"""Check if acquisition involves platform."""
return "platform" in inspect.signature(self.acquisition).parameters
@property
def targets_dependent(self):
"""Check if acquisition involves qubits."""
return "targets" in inspect.signature(self.acquisition).parameters
[docs]@dataclass
class DummyPars(Parameters):
"""Dummy parameters."""
[docs]@dataclass
class DummyData(Data):
"""Dummy data."""
[docs] def save(self, path):
"""Dummy method for saving data."""
[docs]@dataclass
class DummyRes(Results):
"""Dummy results."""
[docs]def _dummy_acquisition(pars: DummyPars, platform: Platform) -> DummyData:
"""Dummy data acquisition."""
return DummyData()
[docs]def _dummy_update(
results: DummyRes, platform: Platform, qubit: Union[QubitId, QubitPairId]
) -> None:
"""Dummy update function."""
dummy_operation = Routine(_dummy_acquisition)
"""Example of a dummy operation."""