From d3af4758394b59396946db3ba1e5f6788a1bac1a Mon Sep 17 00:00:00 2001 From: jessieyu Date: Wed, 16 Jun 2021 20:29:42 -0400 Subject: [PATCH 01/20] move from terra --- qiskit_experiments/store_data/__init__.py | 53 + .../store_data/analysis_result.py | 376 ++++++ .../store_data/device_component.py | 76 ++ qiskit_experiments/store_data/exceptions.py | 33 + .../store_data/experiment_service.py | 436 +++++++ qiskit_experiments/store_data/json.py | 47 + qiskit_experiments/store_data/stored_data.py | 1036 +++++++++++++++++ qiskit_experiments/store_data/utils.py | 219 ++++ test/stored_data/__init__.py | 13 + test/stored_data/test_analysisresult.py | 173 +++ test/stored_data/test_stored_data.py | 712 +++++++++++ 11 files changed, 3174 insertions(+) create mode 100644 qiskit_experiments/store_data/__init__.py create mode 100644 qiskit_experiments/store_data/analysis_result.py create mode 100644 qiskit_experiments/store_data/device_component.py create mode 100644 qiskit_experiments/store_data/exceptions.py create mode 100644 qiskit_experiments/store_data/experiment_service.py create mode 100644 qiskit_experiments/store_data/json.py create mode 100644 qiskit_experiments/store_data/stored_data.py create mode 100644 qiskit_experiments/store_data/utils.py create mode 100644 test/stored_data/__init__.py create mode 100644 test/stored_data/test_analysisresult.py create mode 100644 test/stored_data/test_stored_data.py diff --git a/qiskit_experiments/store_data/__init__.py b/qiskit_experiments/store_data/__init__.py new file mode 100644 index 0000000000..e3ad311c44 --- /dev/null +++ b/qiskit_experiments/store_data/__init__.py @@ -0,0 +1,53 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" +=================================================== +Stored Data (:mod:`qiskit_experiments.stored_data`) +=================================================== + +.. currentmodule:: qiskit_experiments.stored_data + +This module contains the classes used to define the data structure of +an experiment, including its data, metadata, analysis results, and figures. +The classes also provide an interface with a database service for storing +and retrieving experiment-related data. + +Classes +======= + +.. autosummary:: + :toctree: ../stubs/ + + StoredData + StoredDataV1 + AnalysisResult + AnalysisResultV1 + ExperimentService + ExperimentServiceV1 + + +Exceptions +========== + +.. autosummary:: + :toctree: ../stubs/ + + ExperimentError + ExperimentEntryNotFound + ExperimentEntryExists +""" + +from .stored_data import StoredData, StoredDataV1 +from .analysis_result import AnalysisResult, AnalysisResultV1 +from .experiment_service import ExperimentService, ExperimentServiceV1 +from .exceptions import ExperimentError, ExperimentEntryExists, ExperimentEntryNotFound diff --git a/qiskit_experiments/store_data/analysis_result.py b/qiskit_experiments/store_data/analysis_result.py new file mode 100644 index 0000000000..cf3e17530e --- /dev/null +++ b/qiskit_experiments/store_data/analysis_result.py @@ -0,0 +1,376 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Analysis result abstract interface.""" + +import logging +from typing import Optional, List, Union, Dict, Callable, Any +import uuid +import json +import copy +from functools import wraps + +from .json import NumpyEncoder, NumpyDecoder +from .utils import save_data, qiskit_version +from .exceptions import ExperimentError +from .device_component import DeviceComponent, to_component + +LOG = logging.getLogger(__name__) + + +def auto_save(func: Callable): + """Decorate the input function to auto save data.""" + + @wraps(func) + def _wrapped(self, *args, **kwargs): + return_val = func(self, *args, **kwargs) + if self.auto_save: + self.save() + return return_val + + return _wrapped + + +class AnalysisResult: + """Base common type for all versioned AnalysisResult abstract classes. + + Note this class should not be inherited from directly, it is intended + to be used for type checking. When implementing a provider you should use + the versioned abstract classes as the parent class and not this class + directly. + """ + + version = 0 + + +class AnalysisResultV1(AnalysisResult): + """Class representing an analysis result for an experiment.""" + + version = 1 + _data_version = 1 + + _json_encoder = NumpyEncoder + _json_decoder = NumpyDecoder + + _extra_data = {} + + def __init__( + self, + result_data: Dict, + result_type: str, + device_components: List[Union[DeviceComponent, str]], + experiment_id: str, + result_id: Optional[str] = None, + quality: Optional[str] = None, + verified: bool = False, + tags: Optional[List[str]] = None, + service: Optional["ExperimentServiceV1"] = None, + **kwargs, + ): + """AnalysisResult constructor. + + Args: + result_data: Analysis result data. + result_type: Analysis result type. + device_components: Target device components this analysis is for. + experiment_id: ID of the experiment. + result_id: Result ID. If ``None``, one is generated. + quality: Quality of the analysis. Refer to the experiment service + provider for valid values. + verified: Whether the result quality has been verified. + tags: Tags for this analysis result. + service: Experiment service to be used to store result in database. + **kwargs: Additional analysis result attributes. + """ + result_data = result_data or {} + self._result_data = copy.deepcopy(result_data) + self._source = self._result_data.pop( + "_source", + { + "class": f"{self.__class__.__module__}.{self.__class__.__name__}", + "data_version": self._data_version, + "qiskit_version": qiskit_version(), + }, + ) + + # Data to be stored in DB. + self._experiment_id = experiment_id + self._id = result_id or str(uuid.uuid4()) + self._type = result_type + self._device_components = [] + for comp in device_components: + if isinstance(comp, str): + comp = to_component(comp) + self._device_components.append(comp) + + self._quality = quality + self._quality_verified = verified + self._tags = tags or [] + + # Other attributes. + self._service = service + self._created_in_db = False + self.auto_save = False + if self._service: + try: + self.auto_save = self._service.option("auto_save") + except AttributeError: + pass + self._extra_data = kwargs + + def serialize_data(self) -> str: + """Serialize result data into JSON string. + + Returns: + Serialized JSON string. + """ + return json.dumps(self._result_data, cls=self._json_encoder) + + @classmethod + def deserialize_data(cls, data: str) -> Dict: + """Deserialize experiment from JSON string. + + Args: + data: Data to be deserialized. + + Returns: + Deserialized data. + """ + return json.loads(data, cls=cls._json_decoder) + + @classmethod + def from_data( + cls, + result_data: Dict, + result_type: str, + device_components: List[Union[DeviceComponent, str]], + experiment_id: str, + **kwargs, + ) -> "AnalysisResultV1": + """Reconstruct the analysis result from input data. + + Args: + result_data: Analysis result data. + result_type: Analysis result type. + device_components: Target device components this analysis is for. + experiment_id: ID of the experiment. + **kwargs: Additional analysis result attributes. + + Returns: + Reconstructed analysis result. + """ + if result_data: + result_data = cls.deserialize_data(json.dumps(result_data)) + return cls( + result_data=result_data, + result_type=result_type, + device_components=device_components, + experiment_id=experiment_id, + **kwargs, + ) + + def save(self, service: Optional["ExperimentServiceV1"] = None) -> None: + """Save this analysis result in the database. + + Args: + service: Experiment service to be used to save the data. + If ``None``, the default, if any, is used. + + Raises: + ExperimentError: If the analysis result contains invalid data. + """ + service = service or self._service + if not service: + LOG.warning( + "Analysis result cannot be saved because no " "experiment service is available." + ) + return + + _result_data = json.loads(self.serialize_data()) + _result_data["_source"] = self._source + + new_data = { + "experiment_id": self._experiment_id, + "result_type": self.result_type, + "device_components": self.device_components, + } + update_data = { + "result_id": self.result_id, + "data": _result_data, + "tags": self.tags(), + "quality": self.quality, + "verified": self.verified, + } + + self._created_in_db, _ = save_data( + is_new=(not self._created_in_db), + new_func=service.create_analysis_result, + update_func=service.update_analysis_result, + new_data=new_data, + update_data=update_data, + ) + + def data(self) -> Dict: + """Return analysis result data. + + Returns: + Analysis result data. + """ + return self._result_data + + @auto_save + def update_data(self, new_data: Dict) -> None: + """Update result data. + + Args: + new_data: New analysis result data. + """ + self._result_data = new_data + + def tags(self): + """Return tags associated with this result.""" + return self._tags + + @auto_save + def update_tags(self, new_tags: List[str]) -> None: + """Set tags for this result. + + Args: + new_tags: New tags for the result. + """ + self._tags = new_tags + + @property + def result_id(self) -> str: + """Return analysis result ID. + + Returns: + ID for this analysis result. + """ + return self._id + + @property + def result_type(self) -> str: + """Return analysis result type. + + Returns: + Analysis result type. + """ + return self._type + + @property + def source(self) -> Dict: + """Return the class name and version.""" + return self._source + + @property + def quality(self) -> str: + """Return the quality of this analysis. + + Returns: + Quality of this analysis. + """ + return self._quality + + @quality.setter + def quality(self, new_quality: str) -> None: + """Set the quality of this analysis. + + Args: + new_quality: New analysis quality. + """ + self._quality = new_quality + if self.auto_save: + self.save() + + @property + def verified(self) -> bool: + """Return the verified flag. + + The ``verified`` flag is intended to indicate whether the quality + value has been verified by a human. + + Returns: + Whether the quality has been verified. + """ + return self._quality_verified + + @verified.setter + def verified(self, verified: bool) -> None: + """Set the verified flag. + + Args: + verified: Whether the quality is verified. + """ + self._quality_verified = verified + if self.auto_save: + self.save() + + @property + def experiment_id(self) -> str: + """Return the ID of the experiment associated with this analysis result. + + Returns: + ID of experiment associated with this analysis result. + """ + return self._experiment_id + + @property + def service(self) -> Optional["ExperimentServiceV1"]: + """Return the database service. + + Returns: + Service that can be used to store this analysis result in a database. + ``None`` if not available. + """ + return self._service + + @service.setter + def service(self, service: "ExperimentServiceV1") -> None: + """Set the service to be used for storing result data in a database. + + Args: + service: Service to be used. + + Raises: + ExperimentError: If an experiment service is already being used. + """ + if self._service: + raise ExperimentError("An experiment service is already being used.") + self._service = service + + @property + def device_components(self) -> List[DeviceComponent]: + """Return target device components for this analysis result. + + Returns: + Target device components. + """ + return self._device_components + + def __getattr__(self, name: str) -> Any: + try: + return self._extra_data[name] + except KeyError: + # pylint: disable=raise-missing-from + raise AttributeError("Attribute %s is not defined" % name) + + def __str__(self): + ret = f"\nAnalysis Result: {self.result_type}" + ret += f"\nAnalysis Result ID: {self.result_id}" + ret += f"\nExperiment ID: {self.experiment_id}" + ret += f"\nDevice Components: {self.device_components}" + ret += f"\nQuality: {self.quality}" + ret += "\nResult Data:" + for key, val in self.data().items(): + ret += f"\n - {key}: {val}" + return ret diff --git a/qiskit_experiments/store_data/device_component.py b/qiskit_experiments/store_data/device_component.py new file mode 100644 index 0000000000..8c602f94dd --- /dev/null +++ b/qiskit_experiments/store_data/device_component.py @@ -0,0 +1,76 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Device component classes.""" + +from abc import ABC, abstractmethod + + +class DeviceComponent(ABC): + """Class representing a device component.""" + + @abstractmethod + def __str__(self): + pass + + def __repr__(self): + return f"<{self.__class__.__name__}({str(self)})>" + + +class Qubit(DeviceComponent): + """Class representing a qubit device component.""" + + def __init__(self, index: int): + self._index = index + + def __str__(self): + return f"Q{self._index}" + + +class Resonator(DeviceComponent): + """Class representing a resonator device component.""" + + def __init__(self, index: int): + self._index = index + + def __str__(self): + return f"R{self._index}" + + +class UnknownComponent(DeviceComponent): + """Class representing unknown device component.""" + + def __init__(self, component: str): + self._component = component + + def __str__(self): + return self._component + + +def to_component(string: str) -> DeviceComponent: + """Convert the input string to a ``DeviceComponent`` instance. + + Args: + string: String to be converted. + + Returns: + A ``DeviceComponent`` instance. + + Raises: + ValueError: If input string is not a valid device component. + """ + if string.startswith("Q"): + return Qubit(int(string[1:])) + elif string.startswith("R"): + return Resonator(int(string[1:])) + else: + return UnknownComponent(string) diff --git a/qiskit_experiments/store_data/exceptions.py b/qiskit_experiments/store_data/exceptions.py new file mode 100644 index 0000000000..1abfb80456 --- /dev/null +++ b/qiskit_experiments/store_data/exceptions.py @@ -0,0 +1,33 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Exceptions for errors raised while handling experiments.""" + +from qiskit.exceptions import QiskitError + + +class ExperimentError(QiskitError): + """Base class for errors raised while handling experiments.""" + + pass + + +class ExperimentEntryNotFound(ExperimentError): + """Errors raised when an experiment entry cannot be found.""" + + pass + + +class ExperimentEntryExists(ExperimentError): + """Errors raised when an experiment entry already exists.""" + + pass diff --git a/qiskit_experiments/store_data/experiment_service.py b/qiskit_experiments/store_data/experiment_service.py new file mode 100644 index 0000000000..71fa8d0ab1 --- /dev/null +++ b/qiskit_experiments/store_data/experiment_service.py @@ -0,0 +1,436 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Experiment service abstract interface.""" + +from abc import ABC, abstractmethod +from typing import Optional, Dict, List, Any, Union, Tuple, Type, TypeVar + +from .device_component import DeviceComponent + +T = TypeVar('T') + + +class ExperimentService: + """Base common type for all versioned ExperimentService abstract classes. + + Note this class should not be inherited from directly, it is intended + to be used for type checking. When implementing a subclass you should use + the versioned abstract classes as the parent class and not this class + directly. + """ + + version = 0 + + +class ExperimentServiceV1(ExperimentService, ABC): + """Class to provide experiment service. + + The experiment service allows you to store experiment data and metadata + in a database. An experiment can have one or more jobs, analysis results, + and figures. + + Each implementation of this service may use different data structure and + should issue a warning on unsupported keywords. + """ + + version = 1 + + def __init__(self): + """Initialize an ExperimentService instance.""" + self._options = self._default_options() + + @classmethod + @abstractmethod + def _default_options(cls) -> Dict: + """Return the default options + + Returns: + A dictionary of default options. + """ + pass + + @abstractmethod + def create_experiment( + self, + experiment_type: str, + backend_name: str, + metadata: Optional[Dict] = None, + experiment_id: Optional[str] = None, + job_ids: Optional[List[str]] = None, + tags: Optional[List[str]] = None, + notes: Optional[str] = None, + **kwargs: Any, + ) -> str: + """Create a new experiment in the database. + + Args: + experiment_type: Experiment type. + backend_name: Name of the backend the experiment ran on. + metadata: Experiment metadata. + experiment_id: Experiment ID. It must be in the ``uuid4`` format. + One will be generated if not supplied. + job_ids: IDs of experiment jobs. + tags: Tags to be associated with the experiment. + notes: Freeform notes about the experiment. + kwargs: Additional keywords supported by the service provider. + + Returns: + Experiment ID. + + Raises: + ExperimentEntryExists: If the experiment already exits. + """ + pass + + @abstractmethod + def update_experiment( + self, + experiment_id: str, + metadata: Optional[Dict] = None, + job_ids: Optional[List[str]] = None, + notes: Optional[str] = None, + tags: Optional[List[str]] = None, + **kwargs: Any, + ) -> None: + """Update an existing experiment. + + Args: + experiment_id: Experiment ID. + metadata: Experiment metadata. + job_ids: IDs of experiment jobs. + notes: Freeform notes about the experiment. + tags: Tags to be associated with the experiment. + kwargs: Additional keywords supported by the service provider. + + Raises: + ExperimentEntryNotFound: If the experiment does not exist. + """ + pass + + @abstractmethod + def experiment( + self, + experiment_id: str, + experiment_class: Optional[Type[T]] = None + ) -> Union[Dict, T]: + """Retrieve a previously stored experiment. + + Args: + experiment_id: Experiment ID. + experiment_class: Class used to instantiate the returned data object. + If a class is provided, its ``from_data()`` method is called + with the retrieved data, and its return value is returned. + + Returns: + A dictionary containing the retrieved experiment data if `experiment_class` + is ``None``. Otherwise an instance of the `experiment_class` class. + + Raises: + ExperimentEntryNotFound: If the experiment does not exist. + """ + pass + + @abstractmethod + def experiments( + self, + limit: Optional[int] = 10, + experiment_class: Optional[Type[T]] = None, + device_components: Optional[Union[str, DeviceComponent]] = None, + experiment_type: Optional[str] = None, + backend_name: Optional[str] = None, + tags: Optional[List[str]] = None, + tags_operator: Optional[str] = "OR", + **filters: Any, + ) -> List[Union[Dict, T]]: + """Retrieve all experiment data, with optional filtering. + + Args: + limit: Number of experiments to retrieve. ``None`` means no limit. + experiment_class: Class used to instantiate the returned data object. + If a class is provided, its ``from_data()`` method is called + with the retrieved data, and its return value is returned. + device_components: Filter by device components. An experiment must have analysis + results with device components matching the given list exactly to be included. + experiment_type: Experiment type used for filtering. + backend_name: Backend name used for filtering. + tags: Filter by tags assigned to experiments. This can be used + with `tags_operator` for granular filtering. + tags_operator: Logical operator to use when filtering by tags. Valid + values are "AND" and "OR": + + * If "AND" is specified, then an experiment must have all of the tags + specified in `tags` to be included. + * If "OR" is specified, then an experiment only needs to have any + of the tags specified in `tags` to be included. + + **filters: Additional filtering keywords supported by the service provider. + + Returns: + A list of experiments. Each experiment is either a dictionary containing the + retrieved experiment data, if `experiment_class` + is ``None``, or an instance of the `experiment_class` class. + """ + pass + + @abstractmethod + def delete_experiment(self, experiment_id: str) -> None: + """Delete an experiment. + + Args: + experiment_id: Experiment ID. + """ + pass + + @abstractmethod + def create_analysis_result( + self, + experiment_id: str, + data: Dict, + result_type: str, + device_components: Optional[Union[str, DeviceComponent]] = None, + tags: Optional[List[str]] = None, + quality: Optional[str] = None, + verified: bool = False, + result_id: Optional[str] = None, + **kwargs: Any, + ) -> str: + """Create a new analysis result in the database. + + Args: + experiment_id: ID of the experiment this result is for. + data: Result data to be stored. + result_type: Analysis result type. + device_components: Target device components, such as qubits. + tags: Tags to be associated with the analysis result. + quality: Quality of this analysis. + verified: Whether the result quality has been verified. + result_id: Analysis result ID. It must be in the ``uuid4`` format. + One will be generated if not supplied. + kwargs: Additional keywords supported by the service provider. + + Returns: + Analysis result ID. + + Raises: + ExperimentEntryExists: If the analysis result already exits. + """ + pass + + @abstractmethod + def update_analysis_result( + self, + result_id: str, + data: Optional[Dict] = None, + tags: Optional[List[str]] = None, + quality: Optional[str] = None, + verified: bool = None, + **kwargs: Any, + ) -> None: + """Update an existing analysis result. + + Args: + result_id: Analysis result ID. + data: Result data to be stored. + quality: Quality of this analysis. + verified: Whether the result quality has been verified. + tags: Tags to be associated with the analysis result. + kwargs: Additional keywords supported by the service provider. + + Raises: + ExperimentEntryNotFound: If the analysis result does not exist. + """ + pass + + @abstractmethod + def analysis_result( + self, + result_id: str, + result_class: Optional[Type[T]] = None + ) -> Union[Dict, T]: + """Retrieve a previously stored experiment. + + Args: + result_id: Analysis result ID. + result_class: Class used to instantiate the returned data object. + If a class is provided, its ``from_data()`` method is called + with the retrieved data, and its return value is returned. + + Returns: + Retrieved analysis result. + + Raises: + ExperimentEntryNotFound: If the analysis result does not exist. + """ + pass + + @abstractmethod + def analysis_results( + self, + limit: Optional[int] = 10, + result_class: Optional[Type[T]] = None, + device_components: Optional[Union[str, DeviceComponent]] = None, + experiment_id: Optional[str] = None, + result_type: Optional[str] = None, + backend_name: Optional[str] = None, + quality: Optional[str] = None, + verified: Optional[bool] = None, + tags: Optional[List[str]] = None, + tags_operator: Optional[str] = "OR", + **filters: Any, + ) -> List[Union[Dict, T]]: + """Retrieve all analysis results, with optional filtering. + + Args: + limit: Number of analysis results to retrieve. ``None`` means no limit. + result_class: Class used to instantiate the returned data object. + If a class is provided, its ``from_data()`` method is called + with the retrieved data, and its return value is returned. + device_components: Target device components, such as qubits. + experiment_id: Experiment ID used for filtering. + result_type: Analysis result type used for filtering. + backend_name: Backend name used for filtering. If specified, analysis + results associated with experiments on that backend are returned. + quality: Quality value used for filtering. + verified: Whether the result quality has been verified. + tags: Filter by tags assigned to analysis results. This can be used + with `tags_operator` for granular filtering. + tags_operator: Logical operator to use when filtering by tags. Valid + values are "AND" and "OR": + + * If "AND" is specified, then an analysis result must have all of the tags + specified in `tags` to be included. + * If "OR" is specified, then an analysis result only needs to have any + of the tags specified in `tags` to be included. + + **filters: Additional filtering keywords supported by the service provider. + + Returns: + A list of analysis results. Each analysis result is either a dictionary + containing the retrieved analysis result, if `result_class` + is ``None``, or an instance of the `result_class` class. + """ + pass + + @abstractmethod + def delete_analysis_result(self, result_id: str) -> None: + """Delete an analysis result. + + Args: + result_id: Analysis result ID. + """ + pass + + @abstractmethod + def create_figure( + self, experiment_id: str, figure: Union[str, bytes], figure_name: Optional[str] + ) -> Tuple[str, int]: + """Store a new figure in the database. + + Args: + experiment_id: ID of the experiment this figure is for. + figure: Name of the figure file or figure data to store. + figure_name: Name of the figure. If ``None``, the figure file name, if + given, or a generated name is used. + + Returns: + A tuple of the name and size of the saved figure. + + Raises: + ExperimentEntryExists: If the figure already exits. + """ + pass + + @abstractmethod + def update_figure( + self, experiment_id: str, figure: Union[str, bytes], figure_name: str + ) -> Tuple[str, int]: + """Update an existing figure. + + Args: + experiment_id: Experiment ID. + figure: Name of the figure file or figure data to store. + figure_name: Name of the figure. + + Returns: + A tuple of the name and size of the saved figure. + + Raises: + ExperimentEntryNotFound: If the figure does not exist. + """ + pass + + @abstractmethod + def figure( + self, experiment_id: str, figure_name: str, file_name: Optional[str] = None + ) -> Union[int, bytes]: + """Retrieve an existing figure. + + Args: + experiment_id: Experiment ID. + figure_name: Name of the figure. + file_name: Name of the local file to save the figure to. If ``None``, + the content of the figure is returned instead. + + Returns: + The size of the figure if `file_name` is specified. Otherwise the + content of the figure in bytes. + + Raises: + ExperimentEntryNotFound: If the figure does not exist. + """ + pass + + @abstractmethod + def delete_figure( + self, + experiment_id: str, + figure_name: str, + ) -> None: + """Delete an existing figure. + + Args: + experiment_id: Experiment ID. + figure_name: Name of the figure. + """ + pass + + def set_options(self, **fields): + """Set the options fields for the service. + + Args: + fields: The fields to update the options + + Raises: + AttributeError: If the field passed in is not part of the + options + """ + for field in fields: + if field not in self._options: + raise AttributeError("Options field %s is not valid for this " "service." % field) + self._options.update(**fields) + + def option(self, field: str) -> Any: + """Get the value of the specified option. + + Args: + field: Option field to retrieve. + + Returns: + Option value. + + Raises: + AttributeError: If the input field is not valid for the service. + """ + if field not in self._options: + raise AttributeError(f"Options field {field} is not valid for this service.") + return self._options[field] diff --git a/qiskit_experiments/store_data/json.py b/qiskit_experiments/store_data/json.py new file mode 100644 index 0000000000..8dbb37f2cc --- /dev/null +++ b/qiskit_experiments/store_data/json.py @@ -0,0 +1,47 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +# pylint: disable=method-hidden + +"""Experiment serialization methods.""" + +import json +from typing import Any + +import numpy as np + + +class NumpyEncoder(json.JSONEncoder): + """JSON Encoder for Numpy arrays and complex numbers.""" + + def default(self, obj: Any) -> Any: # pylint: disable=arguments-differ + if hasattr(obj, "tolist"): + return {"type": "array", "value": obj.tolist()} + if isinstance(obj, complex): + return {"type": "complex", "value": [obj.real, obj.imag]} + return super().default(obj) + + +class NumpyDecoder(json.JSONDecoder): + """JSON Decoder for Numpy arrays and complex numbers.""" + + def __init__(self, *args, **kwargs): + super().__init__(object_hook=self.object_hook, *args, **kwargs) + + def object_hook(self, obj): + """Object hook.""" + if "type" in obj: + if obj["type"] == "complex": + val = obj["value"] + return val[0] + 1j * val[1] + if obj["type"] == "array": + return np.array(obj["value"]) + return obj diff --git a/qiskit_experiments/store_data/stored_data.py b/qiskit_experiments/store_data/stored_data.py new file mode 100644 index 0000000000..2c40cce9bc --- /dev/null +++ b/qiskit_experiments/store_data/stored_data.py @@ -0,0 +1,1036 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Stored data class.""" + +import logging +import uuid +import json +from typing import Optional, List, Any, Union, Callable, Dict +import copy +from concurrent import futures +from functools import wraps +import traceback +import contextlib +from collections import deque +from datetime import datetime + +from qiskit.providers import Job, BaseJob, Backend, BaseBackend, Provider +from qiskit.result import Result +from qiskit.providers.jobstatus import JobStatus, JOB_FINAL_STATES +from qiskit.providers.exceptions import JobError +from qiskit.visualization import HAS_MATPLOTLIB + +from .exceptions import ExperimentError, ExperimentEntryNotFound, ExperimentEntryExists +from .analysis_result import AnalysisResultV1 as AnalysisResult +from .json import NumpyEncoder, NumpyDecoder +from .utils import ( + save_data, + qiskit_version, + plot_to_svg_bytes, + ThreadSafeOrderedDict, + ThreadSafeList, +) + +LOG = logging.getLogger(__name__) + + +def auto_save(func: Callable): + """Decorate the input function to auto save data.""" + + @wraps(func) + def _wrapped(self, *args, **kwargs): + return_val = func(self, *args, **kwargs) + if self.auto_save: + self.save() + return return_val + + return _wrapped + + +class StoredData: + """Base common type for all versioned StoredData classes. + + Note this class should not be inherited from directly, it is intended + to be used for type checking. When implementing a provider you should use + the versioned abstract classes as the parent class and not this class + directly. + """ + + version = 0 + + +class StoredDataV1(StoredData): + """Class to handle stored data. + + This class serves as a container for data to be stored in a database, which + may include experiment metadata, analysis results, and figures. It also + provides methods used to interact with the database, such as storing into + and retrieving from the database. + """ + + version = 1 + _metadata_version = 1 + _executor = futures.ThreadPoolExecutor() + """Threads used for asynchronous processing.""" + + _json_encoder = NumpyEncoder + _json_decoder = NumpyDecoder + + def __init__( + self, + experiment_type: str, + backend: Optional[Union[Backend, BaseBackend]] = None, + experiment_id: Optional[str] = None, + tags: Optional[List[str]] = None, + job_ids: Optional[List[str]] = None, + share_level: Optional[str] = None, + metadata: Optional[Dict] = None, + figure_names: Optional[List[str]] = None, + notes: Optional[str] = None, + **kwargs, + ): + """Initializes the StoredDataV1 instance. + + Args: + experiment_type: Experiment type. + backend: Backend the experiment runs on. + experiment_id: Experiment ID. One will be generated if not supplied. + tags: Tags to be associated with the experiment. + job_ids: IDs of jobs submitted for the experiment. + share_level: Whether this experiment can be shared with others. This + is applicable only if the experiment service supports sharing. See + the specific service provider's documentation on valid values. + metadata: Additional experiment metadata. + figure_names: Name of figures associated with this experiment. + notes: Freeform notes about the experiment. + **kwargs: Additional experiment attributes. + + Raises: + ExperimentError: If an input argument is invalid. + """ + metadata = metadata or {} + self._metadata = copy.deepcopy(metadata) + self._source = self._metadata.pop( + "_source", + { + "class": f"{self.__class__.__module__}.{self.__class__.__name__}", + "metadata_version": self._metadata_version, + "qiskit_version": qiskit_version(), + }, + ) + + self._service = None + self._backend = backend + self.auto_save = False + self._set_service_from_backend(backend) + + self._id = experiment_id or str(uuid.uuid4()) + self._type = experiment_type + self._tags = tags or [] + self._share_level = share_level + self._notes = notes or "" + + self._jobs = ThreadSafeOrderedDict(job_ids or []) + self._job_futures = ThreadSafeList() + self._errors = [] + + self._data = ThreadSafeList() + self._figures = ThreadSafeOrderedDict(figure_names or []) + self._analysis_results = ThreadSafeOrderedDict() + + self._deleted_figures = deque() + self._deleted_analysis_results = deque() + + self._created_in_db = False + self._extra_data = kwargs + + def _set_service_from_backend(self, backend: Union[Backend, BaseBackend]) -> None: + """Set the service to be used from the input backend. + + Args: + backend: Backend whose provider may offer experiment service. + """ + with contextlib.suppress(Exception): + self._service = backend.provider().service("experiment") + with contextlib.suppress(Exception): + self.auto_save = self._service.option("auto_save") + + def add_data( + self, + data: Union[Result, List[Result], Job, List[Job], Dict, List[Dict]], + post_processing_callback: Optional[Callable] = None, + **kwargs: Any, + ) -> None: + """Add experiment data. + + Note: + This method is not thread safe and should not be called by the + `post_processing_callback` function. + + Note: + If `data` is a ``Job``, this method waits for the job to finish + and calls the `post_processing_callback` function asynchronously. + + Args: + data: Experiment data to add. + Several types are accepted for convenience: + + * Result: Add data from this ``Result`` object. + * List[Result]: Add data from the ``Result`` objects. + * Job: Add data from the job result. + * List[Job]: Add data from the job results. + * Dict: Add this data. + * List[Dict]: Add this list of data. + + post_processing_callback: Callback function invoked when data is + added. If `data` is a ``Job``, the callback is only invoked when + the job finishes successfully. + The following positional arguments are provided to the callback function: + + * This ``StoredData`` object. + * Index of the data added. + * Additional keyword arguments passed to this method. + + **kwargs: Keyword arguments to be passed to the callback function. + + Raises: + TypeError: If the input data type is invalid. + """ + if isinstance(data, (Job, BaseJob)): + if self.backend and self.backend.name() != data.backend().name(): + LOG.warning( + "Adding a job from a backend (%s) that is different " + "than the current backend (%s). " + "The new backend will be used, but " + "service is not changed if one already exists.", + data.backend(), + self.backend, + ) + self._backend = data.backend() + if not self._service: + self._set_service_from_backend(self._backend) + + self._jobs[data.job_id()] = data + self._job_futures.append( + ( + data, + self._executor.submit( + self._wait_for_job, data, post_processing_callback, **kwargs + ), + ) + ) + if self.auto_save: + self.save() + return + + if isinstance(data, dict): + self._add_single_data(data) + elif isinstance(data, Result): + self._add_result_data(data) + elif isinstance(data, list): + # TODO use loop instead of recursion for fewer save() + for dat in data: + self.add_data(dat) + else: + raise TypeError(f"Invalid data type {type(data)}.") + + if post_processing_callback is not None: + post_processing_callback(self, len(self._data)-1, **kwargs) + + def _wait_for_job( + self, + job: Union[Job, BaseJob], + job_done_callback: Optional[Callable] = None, + **kwargs: Any, + ) -> None: + """Wait for a job to finish. + + Args: + job: Job to wait for. + job_done_callback: Callback function to invoke when job finishes. + **kwargs: Keyword arguments to be passed to the callback function. + """ + LOG.debug("Waiting for job %s to finish.", job.job_id()) + try: + job_result = job.result() + with self._data.lock: + # Hold the lock so we add the block of results together. + self._add_result_data(job_result) + data_index = len(self._data)-1 + except JobError as err: + LOG.warning("Job %s failed: %s", job.job_id(), str(err)) + return + if job_done_callback: + job_done_callback(self, data_index, **kwargs) + + def _add_result_data(self, result: Result) -> None: + """Add data from a Result object + + Args: + result: Result object containing data to be added. + """ + if result.job_id not in self._jobs: + self._jobs[result.job_id] = None + for i in range(len(result.results)): + data = result.data(i) + data["job_id"] = result.job_id + if "counts" in data: + # Format to Counts object rather than hex dict + data["counts"] = result.get_counts(i) + expr_result = result.results[i] + if hasattr(expr_result, "header") and hasattr(expr_result.header, "metadata"): + data["metadata"] = expr_result.header.metadata + self._add_single_data(data) + + def _add_single_data(self, data: Dict[str, any]) -> None: + """Add a single data dictionary to the experiment. + + Args: + data: Data to be added. + """ + self._data.append(data) + + def data(self, index: Optional[Union[int, slice, str]] = None) -> Union[Dict, List[Dict]]: + """Return the experiment data at the specified index. + + Args: + index: Index of the data to be returned. + Several types are accepted for convenience: + + * None: Return all experiment data. + * int: Specific index of the data. + * slice: A list slice of data indexes. + * str: ID of the job that produced the data. + + Returns: + Experiment data. + + Raises: + TypeError: If the input `index` has an invalid type. + """ + # Get job results if missing experiment data. + if (not self._data) and self._provider: + with self._jobs.lock: + for jid in self._jobs: + if self._jobs[jid] is None: + try: + self._jobs[jid] = self._provider.retrieve_job(jid) + except Exception: # pylint: disable=broad-except + pass + if self._jobs[jid] is not None: + self._add_result_data(self._jobs[jid].result()) + + if index is None: + return self._data.copy() + if isinstance(index, (int, slice)): + return self._data[index] + if isinstance(index, str): + return [data for data in self._data if data.get("job_id") == index] + raise TypeError(f"Invalid index type {type(index)}.") + + @auto_save + def add_figures( + self, + figures: Union[List[Union[str, bytes, "pyplot.Figure"]], str, bytes, "pyplot.Figure"], + figure_names: Optional[Union[List[str], str]] = None, + overwrite: bool = False, + save_figure: Optional[bool] = None, + service: Optional["ExperimentServiceV1"] = None, + ) -> Union[str, List[str]]: + """Add the experiment figure. + + Args: + figures: Names of the figure files or figure data. + figure_names: Names of the figures. If ``None``, use the figure file + names, if given, or a generated name. If `figures` is a list, then + `figure_names` must also be a list of the same length or ``None``. + overwrite: Whether to overwrite the figure if one already exists with + the same name. + save_figure: Whether to save the figure in the database. If ``None``, + the ``auto-save`` attribute is used. + service: Experiment service to be used to update the database, if + the figure is to be uploaded. If ``None``, the default service is used. + + Returns: + Figure names. + + Raises: + ExperimentEntryExists: If the figure with the same name already exists, + and `overwrite=True` is not specified. + ValueError: If an input parameter has an invalid value. + """ + if ( + isinstance(figures, list) + and figure_names is not None + and (not isinstance(figure_names, list) or len(figures) != len(figure_names)) + ): + raise ValueError( + "The parameter figure_names must be None or a list of " + "the same size as the parameter figures." + ) + if not isinstance(figures, list): + figures = [figures] + if figure_names is not None and not isinstance(figure_names, list): + figure_names = [figure_names] + + added_figs = [] + for idx, figure in enumerate(figures): + if figure_names is None: + if isinstance(figure, str): + fig_name = figure + else: + fig_name = ( + f"figure_{self.experiment_id[:8]}_" + f"{datetime.now().isoformat()}_{len(self._figures)}" + ) + else: + fig_name = figure_names[idx] + + existing_figure = fig_name in self._figures + if existing_figure and not overwrite: + raise ExperimentEntryExists( + f"A figure with the name {fig_name} for this experiment " + f"already exists. Specify overwrite=True if you " + f"want to overwrite it." + ) + # figure_data = None + if isinstance(figure, str): + with open(figure, "rb") as file: + figure = file.read() + + self._figures[fig_name] = figure + + service = service or self._service + save = save_figure if save_figure is not None else self.auto_save + if save and service: + if HAS_MATPLOTLIB: + from matplotlib import pyplot + + if isinstance(figure, pyplot.Figure): + figure = plot_to_svg_bytes(figure) + data = { + "experiment_id": self.experiment_id, + "figure": figure, + "figure_name": fig_name, + } + save_data( + is_new=(not existing_figure), + new_func=service.create_figure, + update_func=service.update_figure, + new_data={}, + update_data=data, + ) + added_figs.append(fig_name) + + return added_figs if len(added_figs) > 1 else added_figs[0] + + @auto_save + def delete_figure( + self, + figure_key: Union[str, int], + service: Optional["ExperimentServiceV1"] = None, + ) -> str: + """Add the experiment figure. + + Args: + figure_key: Name or index of the figure. + service: Experiment service to be used to update the database. If ``None``, + the default service is used. + + Returns: + Figure name. + + Raises: + ExperimentEntryNotFound: If the figure is not found. + """ + if isinstance(figure_key, int): + figure_key = self._figures.keys()[figure_key] + elif figure_key not in self._figures: + raise ExperimentEntryNotFound(f"Figure {figure_key} not found.") + + del self._figures[figure_key] + self._deleted_figures.append(figure_key) + + service = service or self._service + if service and self.auto_save: + with contextlib.suppress(ExperimentEntryNotFound): + self.service.delete_figure(experiment_id=self.experiment_id, figure_name=figure_key) + self._deleted_figures.remove(figure_key) + + return figure_key + + def figure( + self, figure_key: Union[str, int], file_name: Optional[str] = None + ) -> Union[int, bytes]: + """Retrieve the specified experiment figure. + + Args: + figure_key: Name or index of the figure. + file_name: Name of the local file to save the figure to. If ``None``, + the content of the figure is returned instead. + + Returns: + The size of the figure if `file_name` is specified. Otherwise the + content of the figure in bytes. + + Raises: + ExperimentEntryNotFound: If the figure cannot be found. + """ + if isinstance(figure_key, int): + figure_key = self._figures.keys()[figure_key] + + figure_data = self._figures.get(figure_key, None) + if figure_data is None and self.service: + figure_data = self.service.figure( + experiment_id=self.experiment_id, figure_name=figure_key + ) + self._figures[figure_key] = figure_data + + if figure_data is None: + raise ExperimentEntryNotFound(f"Figure {figure_key} not found.") + + if file_name: + with open(file_name, "wb") as output: + num_bytes = output.write(figure_data) + return num_bytes + return figure_data + + @auto_save + def add_analysis_results( + self, + results: Union[AnalysisResult, List[AnalysisResult]], + service: "ExperimentServiceV1" = None, + ) -> None: + """Save the analysis result. + + Args: + results: Analysis results to be saved. + service: Experiment service to be used to update the database. If ``None``, + the default service is used. + """ + if not isinstance(results, list): + results = [results] + + for result in results: + self._analysis_results[result.result_id] = result + + with contextlib.suppress(ExperimentError): + result.service = self.service + result.auto_save = self.auto_save + + use_service = service or self.service + if self.auto_save and use_service: + result.save(service=service) + + @auto_save + def delete_analysis_result( + self, result_key: Union[int, str], service: "ExperimentServiceV1" = None + ) -> str: + """Delete the analysis result. + + Args: + result_key: ID or index of the analysis result to be delete. + service: Experiment service to be used to update the database. If ``None``, + the default service is used. + + Returns: + Analysis result ID. + + Raises: + ExperimentEntryNotFound: If analysis result not found. + """ + if isinstance(result_key, int): + result_key = self._analysis_results.keys()[result_key] + elif result_key not in self._analysis_results: + raise ExperimentEntryNotFound(f"Analysis result {result_key} not found.") + + del self._analysis_results[result_key] + self._deleted_analysis_results.append(result_key) + + service = service or self._service + if service and self.auto_save: + with contextlib.suppress(ExperimentEntryNotFound): + self.service.delete_analysis_result(result_id=result_key) + self._deleted_analysis_results.remove(result_key) + + return result_key + + def analysis_result( + self, index: Optional[Union[int, slice, str]] = None, refresh: bool = False + ) -> Union[AnalysisResult, List[AnalysisResult]]: + """Return analysis results associated with this experiment. + + Args: + index: Index of the analysis result to be returned. + Several types are accepted for convenience: + + * None: Return all analysis results. + * int: Specific index of the analysis results. + * slice: A list slice of indexes. + * str: ID of the analysis result. + refresh: Retrieve the latest analysis results from the server, if + an experiment service is available. + + Returns: + Analysis results for this experiment. + + Raises: + TypeError: If the input `index` has an invalid type. + ExperimentEntryNotFound: If the entry cannot be found. + """ + if self.service and (not self._analysis_results or refresh): + retrieved_results = self.service.analysis_results( + experiment_id=self.experiment_id, limit=None + ) + for result in retrieved_results: + self._analysis_results[result.result_id] = result + + if index is None: + return self._analysis_results.values() + if isinstance(index, (int, slice)): + return self._analysis_results.values()[index] + if isinstance(index, str): + if index not in self._analysis_results: + raise ExperimentEntryNotFound(f"Analysis result {index} not found.") + return self._analysis_results[index] + + raise TypeError(f"Invalid index type {type(index)}.") + + def save(self, service: Optional["ExperimentServiceV1"] = None) -> None: + """Save this experiment in the database. + + Note: + Not all experiment properties are saved. + See :meth:`qiskit.providers.experiment.ExperimentServiceV1.create_experiment` + for fields that are saved. + + Note: + Note that this method does not save analysis results nor figures. Use + ``save_all()`` if you want to save those. + + Args: + service: Experiment service to be used to save the data. + If ``None``, the provider used to submit jobs will be used. + + Raises: + ExperimentError: If the experiment contains invalid data. + """ + service = service or self._service + if not service: + LOG.warning("Experiment cannot be saved because no experiment service is available.") + return + + if not self._backend: + LOG.warning("Experiment cannot be saved because backend is missing.") + return + + metadata = json.loads(self.serialize_metadata()) + metadata["_source"] = self._source + + update_data = { + "experiment_id": self._id, + "metadata": metadata, + "job_ids": self.job_ids, + "tags": self.tags(), + "notes": self.notes, + } + new_data = {"experiment_type": self._type, "backend_name": self._backend.name()} + if self.share_level: + update_data["share_level"] = self.share_level + + self._created_in_db, _ = save_data( + is_new=(not self._created_in_db), + new_func=service.create_experiment, + update_func=service.update_experiment, + new_data=new_data, + update_data=update_data, + ) + + def save_all(self, service: Optional["ExperimentServiceV1"] = None) -> None: + """Save this experiment and its analysis results and figures in the database. + + Note: + Depending on the amount of data, this operation could take a while. + + Args: + service: Experiment service to be used to save the data. + If ``None``, the provider used to submit jobs will be used. + + Raises: + ExperimentError: If the experiment contains invalid data. + """ + # TODO - track changes + use_service = service or self._service + if not use_service: + LOG.warning("Experiment cannot be saved because no experiment service is available.") + return + + self.save(service=use_service) + for result in self._analysis_results.values(): + result.save(service) + + for result in self._deleted_analysis_results.copy(): + try: + use_service.delete_analysis_result(result_id=result) + except ExperimentEntryNotFound: + pass + self._deleted_analysis_results.remove(result) + + with self._figures.lock: + for name, figure in self._figures.items(): + if HAS_MATPLOTLIB: + from matplotlib import pyplot + + if isinstance(figure, pyplot.Figure): + figure = plot_to_svg_bytes(figure) + data = {"experiment_id": self.experiment_id, "figure": figure, "figure_name": name} + save_data( + is_new=True, + new_func=use_service.create_figure, + update_func=use_service.update_figure, + new_data={}, + update_data=data, + ) + + for name in self._deleted_figures.copy(): + try: + use_service.delete_figure(experiment_id=self.experiment_id, figure_name=name) + except ExperimentEntryNotFound: + pass + self._deleted_figures.remove(name) + + def serialize_metadata(self) -> str: + """Serialize experiment metadata into a JSON string. + + Returns: + Serialized JSON string. + """ + return json.dumps(self._metadata, cls=self._json_encoder) + + @classmethod + def deserialize_metadata(cls, data: str) -> Any: + """Deserialize experiment metadata from a JSON string. + + Args: + data: Data to be deserialized. + + Returns: + Deserialized data. + """ + return json.loads(data, cls=cls._json_decoder) + + @classmethod + def from_data( + cls, + experiment_type: str, + experiment_id: str, + metadata: Optional[Dict] = None, + **kwargs, + ) -> "StoredDataV1": + """Reconstruct a StoredData using the input data. + + Args: + experiment_type: Experiment type. + experiment_id: Experiment ID. One will be generated if not supplied. + metadata: Additional experiment metadata. + **kwargs: Additional experiment attributes. + + Returns: + An StoredData instance. + """ + if metadata: + metadata = cls.deserialize_metadata(json.dumps(metadata)) + return cls( + experiment_type=experiment_type, + experiment_id=experiment_id, + metadata=metadata, + **kwargs, + ) + + def cancel_jobs(self) -> None: + """Cancel any running jobs.""" + for job, fut in self._job_futures.copy(): + if not fut.done() and job.status() not in JOB_FINAL_STATES: + try: + job.cancel() + except Exception as err: # pylint: disable=broad-except + LOG.info("Unable to cancel job %s: %s", job.job_id(), err) + + def block_for_results(self) -> None: + """Block until all pending jobs and their post processing finish.""" + for job, fut in self._job_futures.copy(): + LOG.info("Waiting for job %s and its post processing to finish.", job.job_id()) + with contextlib.suppress(Exception): + fut.result() + + def status(self) -> str: + """Return the data processing status. + + If the experiment consists of multiple jobs, the returned status is mapped + in the following order: + + * INITIALIZING - if any job is being initialized. + * VALIDATING - if any job is being validated. + * QUEUED - if any job is queued. + * RUNNING - if any job is still running. + * ERROR - if any job incurred an error. + * CANCELLED - if any job is cancelled. + * POST_PROCESSING - if any of the post-processing functions is still running. + * DONE - if all jobs and their post-processing functions finished. + + Returns: + Data processing status. + """ + if all( + len(container) == 0 + for container in [self._data, self._jobs, self._figures, self._analysis_results] + ): + return "INITIALIZING" + + statuses = set() + with self._job_futures.lock: + for idx, item in enumerate(self._job_futures): + job, fut = item + job_status = job.status() + statuses.add(job_status) + if job_status == JobStatus.ERROR: + job_err = "." + if hasattr(job, "error_message"): + job_err = ": " + job.error_message() + self._errors.append(f"Job {job.job_id()} failed{job_err}") + + if fut.done(): + self._job_futures[idx] = None + ex = fut.exception() + if ex: + self._errors.append( + f"Post processing for job {job.job_id()} failed: \n" + + "".join(traceback.format_exception(type(ex), ex, ex.__traceback__)) + ) + statuses.add(JobStatus.ERROR) + + self._job_futures = ThreadSafeList(list(filter(None, self._job_futures))) + + for stat in [ + JobStatus.INITIALIZING, + JobStatus.VALIDATING, + JobStatus.QUEUED, + JobStatus.RUNNING, + JobStatus.ERROR, + JobStatus.CANCELLED, + ]: + if stat in statuses: + return stat.name + + if self._job_futures: + return "POST_PROCESSING" + + return "DONE" + + def errors(self) -> str: + """Return errors encountered. + + Returns: + Experiment errors. + """ + self.status() # Collect new errors. + return "\n".join(self._errors) + + def tags(self) -> List[str]: + """Return tags assigned to this experiment data. + + Returns: + A list of tags assigned to this experiment data. + + """ + return self._tags + + @auto_save + def update_tags(self, new_tags: List[str]) -> None: + """Set tags for this experiment. + + Args: + new_tags: New tags for the experiment. + """ + self._tags = new_tags + + def metadata(self) -> Dict: + """Return experiment metadata. + + Returns: + Experiment metadata. + """ + return self._metadata + + @auto_save + def update_metadata(self, metadata: Dict) -> None: + """Update metadata for this experiment. + + Args: + metadata: New metadata for the experiment. + """ + self._metadata = copy.deepcopy(metadata) + + @property + def _provider(self) -> Optional[Provider]: + """Return the provider. + + Returns: + Provider used for the experiment, or ``None`` if unknown. + """ + if self._backend is None: + return None + return self._backend.provider() + + @property + def experiment_id(self) -> str: + """Return experiment ID + + Returns: + Experiment ID. + """ + return self._id + + @property + def job_ids(self) -> List[str]: + """Return experiment job IDs. + + Returns: IDs of jobs submitted for this experiment. + """ + return self._jobs.keys() + + @property + def backend(self) -> Optional[Union[BaseBackend, Backend]]: + """Return backend. + + Returns: + Backend this experiment is for, or ``None`` if backend is unknown. + """ + return self._backend + + @property + def experiment_type(self) -> str: + """Return experiment type. + + Returns: + Experiment type. + """ + return self._type + + @property + def figure_names(self) -> List[str]: + """Return names of the figures associated with this experiment. + + Returns: + Names of figures associated with this experiment. + """ + return self._figures.keys() + + @property + def share_level(self) -> str: + """Return the share level fo this experiment. + + Returns: + Experiment share level. + """ + return self._share_level + + @share_level.setter + def share_level(self, new_level: str) -> None: + """Set the experiment share level. + + Args: + new_level: New experiment share level. Valid share levels are provider- + specified. For example, IBMQ allows "global", "hub", "group", + "project", and "private". + """ + self._share_level = new_level + if self.auto_save: + self.save() + + @property + def notes(self) -> str: + """Return experiment notes. + + Returns: + Experiment notes. + """ + return self._notes + + @notes.setter + def notes(self, new_notes: str) -> None: + """Update experiment notes. + + Args: + new_notes: New experiment notes. + """ + self._notes = new_notes + if self.auto_save: + self.save() + + @property + def service(self) -> Optional["ExperimentServiceV1"]: + """Return the database service. + + Returns: + Service that can be used to access this experiment in a database. + """ + return self._service + + @service.setter + def service(self, service: "ExperimentServiceV1") -> None: + """Set the service to be used for storing experiment data remotely. + + Args: + service: Service to be used. + + Raises: + ExperimentError: If a remote experiment service is already being used. + """ + if self._service: + raise ExperimentError("An experiment service is already being used.") + self._service = service + with contextlib.suppress(Exception): + self.auto_save = self._service.option("auto_save") + + @property + def source(self) -> Dict: + """Return the class name and version.""" + return self._source + + def __str__(self): + line = 51 * "-" + n_res = len(self._analysis_results) + status = self.status() + ret = line + ret += f"\nExperiment: {self.experiment_type}" + ret += f"\nExperiment ID: {self.experiment_id}" + ret += f"\nStatus: {status}" + if status == "ERROR": + ret += "\n " + ret += "\n ".join(self._errors) + ret += f"\nData: {len(self._data)}" + ret += f"\nAnalysis Results: {n_res}" + ret += f"\nFigures: {len(self._figures)}" + ret += "\n" + line + if n_res: + ret += "\nLast Analysis Result:" + ret += f"\n{str(self._analysis_results.values()[-1])}" + return ret + + def __getattr__(self, name: str) -> Any: + try: + return self._extra_data[name] + except KeyError: + # pylint: disable=raise-missing-from + raise AttributeError("Attribute %s is not defined" % name) diff --git a/qiskit_experiments/store_data/utils.py b/qiskit_experiments/store_data/utils.py new file mode 100644 index 0000000000..66a0b6f6e1 --- /dev/null +++ b/qiskit_experiments/store_data/utils.py @@ -0,0 +1,219 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Experiment utility functions.""" + +import logging +from typing import Callable, Tuple, Dict, Any, Union +import io +from datetime import datetime, timezone +import threading +from collections import OrderedDict +from abc import ABC, abstractmethod +import traceback +import pkg_resources + +import dateutil.parser +from dateutil import tz + +from qiskit.version import __version__ as terra_version + +from .exceptions import ExperimentEntryNotFound, ExperimentEntryExists, ExperimentError + +LOG = logging.getLogger(__name__) + + +def qiskit_version(): + """Return the Qiskit version.""" + try: + return pkg_resources.get_distribution("qiskit").version + except Exception: # pylint: disable=broad-except + return {"qiskit-terra": terra_version} + + +def parse_timestamp(utc_dt: Union[datetime, str]) -> datetime: + """Parse a UTC ``datetime`` object or string. + + Args: + utc_dt: Input UTC `datetime` or string. + + Returns: + A ``datetime`` with the UTC timezone. + + Raises: + TypeError: If the input parameter value is not valid. + """ + if isinstance(utc_dt, str): + utc_dt = dateutil.parser.parse(utc_dt) + if not isinstance(utc_dt, datetime): + raise TypeError("Input `utc_dt` is not string or datetime.") + utc_dt = utc_dt.replace(tzinfo=timezone.utc) + return utc_dt + + +def utc_to_local(utc_dt: datetime) -> datetime: + """Convert input UTC timestamp to local timezone. + + Args: + utc_dt: Input UTC timestamp. + + Returns: + A ``datetime`` with the local timezone. + """ + local_dt = utc_dt.astimezone(tz.tzlocal()) + return local_dt + + +def plot_to_svg_bytes(figure: "pyplot.Figure") -> bytes: + """Convert a pyplot Figure to SVG in bytes. + + Args: + figure: Figure to be converted + + Returns: + Figure in bytes. + """ + buf = io.BytesIO() + opaque_color = list(figure.get_facecolor()) + opaque_color[3] = 1.0 # set alpha to opaque + figure.savefig(buf, format="svg", facecolor=tuple(opaque_color), edgecolor="none") + buf.seek(0) + figure_data = buf.read() + buf.close() + return figure_data + + +def save_data( + is_new: bool, new_func: Callable, update_func: Callable, new_data: Dict, update_data: Dict +) -> Tuple[bool, Any]: + """Save data in the database. + + Args: + is_new: ``True`` if `new_func` should be called. Otherwise `update_func` is called. + new_func: Function to create new entry in the database. + update_func: Function to update an existing entry in the database. + new_data: In addition to `update_data`, this data will be stored if creating + a new entry. + update_data: Data to be stored if updating an existing entry. + + Returns: + A tuple of whether the data was saved and the function return value. + + Raises: + ExperimentError: If unable to determine whether the entry exists. + """ + attempts = 0 + try: + # Attempt 3x for the unlikely scenario wherein is_new=False but the + # entry doesn't actually exists. The second try might also fail if an entry + # with the same ID somehow got created in the meantime. + while attempts < 3: + attempts += 1 + if is_new: + try: + return True, new_func(**{**new_data, **update_data}) + except ExperimentEntryExists: + is_new = False + else: + try: + return True, update_func(**update_data) + except ExperimentEntryNotFound: + is_new = True + raise ExperimentError("Unable to determine the existence of the entry.") + except Exception: # pylint: disable=broad-except + # Don't fail the experiment just because its data cannot be saved. + LOG.error("Unable to save the experiment data: %s", traceback.format_exc()) + return False, None + + +class ThreadSafeContainer(ABC): + """Base class for thread safe container.""" + + def __init__(self, init_values=None): + """ThreadSafeContainer constructor.""" + self._lock = threading.RLock() + self._container = self._init_container(init_values) + + @abstractmethod + def _init_container(self, init_values): + """Initialize the container.""" + pass + + def __getitem__(self, key): + with self._lock: + return self._container[key] + + def __setitem__(self, key, value): + with self._lock: + self._container[key] = value + + def __delitem__(self, key): + with self._lock: + del self._container[key] + + def __contains__(self, item): + with self._lock: + return item in self._container + + def __len__(self): + with self._lock: + return len(self._container) + + @property + def lock(self): + """Return lock used for this container.""" + return self._lock + + +class ThreadSafeOrderedDict(ThreadSafeContainer): + """Thread safe OrderedDict.""" + + def _init_container(self, init_values): + """Initialize the container.""" + return OrderedDict.fromkeys(init_values or []) + + def get(self, key, default): + """Return the value of the given key.""" + with self._lock: + return self._container.get(key, default) + + def keys(self): + """Return all key values.""" + with self._lock: + return list(self._container.keys()) + + def values(self): + """Return all values.""" + with self._lock: + return list(self._container.values()) + + def items(self): + """Return the key value pairs.""" + return self._container.items() + + +class ThreadSafeList(ThreadSafeContainer): + """Thread safe list.""" + + def _init_container(self, init_values): + """Initialize the container.""" + return init_values or [] + + def append(self, value): + """Append to the list.""" + with self._lock: + self._container.append(value) + + def copy(self): + """Returns a copy of the list.""" + with self.lock: + return self._container.copy() diff --git a/test/stored_data/__init__.py b/test/stored_data/__init__.py new file mode 100644 index 0000000000..62b7e61d2b --- /dev/null +++ b/test/stored_data/__init__.py @@ -0,0 +1,13 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Test cases for stored data module.""" diff --git a/test/stored_data/test_analysisresult.py b/test/stored_data/test_analysisresult.py new file mode 100644 index 0000000000..6a41506997 --- /dev/null +++ b/test/stored_data/test_analysisresult.py @@ -0,0 +1,173 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +# pylint: disable=missing-docstring + +"""Test AnalysisResult.""" + +from unittest import mock +import json + +import numpy as np + +from qiskit.test import QiskitTestCase +from qiskit_experiments.store_data import AnalysisResultV1 as AnalysisResult +from qiskit_experiments.store_data.device_component import Qubit, Resonator, to_component +from qiskit_experiments.store_data.experiment_service import ExperimentServiceV1 +from qiskit_experiments.store_data.exceptions import ExperimentError + + +class TestAnalysisResult(QiskitTestCase): + """Test the AnalysisResult class.""" + + def test_analysis_result_attributes(self): + """Test analysis result attributes.""" + attrs = { + "result_type": "my_type", + "device_components": [Qubit(1), Qubit(2)], + "experiment_id": "1234", + "result_id": "5678", + "quality": "Good", + "verified": False, + } + result = AnalysisResult(result_data={"foo": "bar"}, tags=["tag1", "tag2"], **attrs) + self.assertEqual({"foo": "bar"}, result.data()) + self.assertEqual(["tag1", "tag2"], result.tags()) + for key, val in attrs.items(): + self.assertEqual(val, getattr(result, key)) + + def test_save(self): + """Test saving analysis result.""" + mock_service = mock.create_autospec(ExperimentServiceV1) + result = self._new_analysis_result() + result.save(service=mock_service) + mock_service.create_analysis_result.assert_called_once() + + def test_auto_save(self): + """Test auto saving.""" + mock_service = mock.create_autospec(ExperimentServiceV1) + result = self._new_analysis_result(service=mock_service) + result.auto_save = True + result.save() + + subtests = [ + # update function, update parameters, service called + (result.update_tags, (["foo"],)), + (result.update_data, ({"foo": "bar"},)), + (setattr, (result, "quality", "GOOD")), + (setattr, (result, "verified", True)), + ] + + for func, params in subtests: + with self.subTest(func=func): + func(*params) + mock_service.update_analysis_result.assert_called_once() + mock_service.reset_mock() + + def test_set_service_init(self): + """Test setting service in init.""" + mock_service = mock.create_autospec(ExperimentServiceV1) + result = self._new_analysis_result(service=mock_service) + self.assertEqual(mock_service, result.service) + + def test_set_service_direct(self): + """Test setting service directly.""" + mock_service = mock.create_autospec(ExperimentServiceV1) + result = self._new_analysis_result() + result.service = mock_service + self.assertEqual(mock_service, result.service) + + with self.assertRaises(ExperimentError): + result.service = mock_service + + def test_set_service_save(self): + """Test setting service when saving.""" + orig_service = mock.create_autospec(ExperimentServiceV1) + result = self._new_analysis_result(service=orig_service) + new_service = mock.create_autospec(ExperimentServiceV1) + result.save(service=new_service) + new_service.create_analysis_result.assert_called() + orig_service.create_analysis_result.assert_not_called() + + def test_update_data(self): + """Test updating data.""" + result = self._new_analysis_result() + result.update_data({"foo": "new data"}) + self.assertEqual({"foo": "new data"}, result.data()) + + def test_update_tags(self): + """Test updating tags.""" + result = self._new_analysis_result() + result.update_tags(["new_tag"]) + self.assertEqual(["new_tag"], result.tags()) + + def test_update_quality(self): + """Test updating quality.""" + result = self._new_analysis_result(quality="BAD") + result.quality = "GOOD" + self.assertEqual("GOOD", result.quality) + + def test_update_verified(self): + """Test updating verified.""" + result = self._new_analysis_result(verified=False) + result.verified = True + self.assertTrue(result.verified) + + def test_additional_attr(self): + """Test additional attributes.""" + result = self._new_analysis_result(foo="bar") + self.assertEqual("bar", result.foo) + + def test_data_serialization(self): + """Test result data serialization.""" + result = self._new_analysis_result(result_data={"complex": 2 + 3j, "numpy": np.zeros(2)}) + serialized = result.serialize_data() + self.assertIsInstance(serialized, str) + self.assertTrue(json.loads(serialized)) + + def test_source(self): + """Test getting analysis result source.""" + result = self._new_analysis_result() + source_vals = "\n".join([str(val) for val in result.source.values()]) + self.assertIn("AnalysisResultV1", source_vals) + self.assertIn("qiskit-terra", source_vals) + + def _new_analysis_result(self, **kwargs): + """Return a new analysis result.""" + values = { + "result_data": {"foo": "bar"}, + "result_type": "some_type", + "device_components": ["Q1", "Q1"], + "experiment_id": "1234", + } + values.update(kwargs) + return AnalysisResult(**values) + + +class TestDeviceComponent(QiskitTestCase): + """Test the DeviceComponent class.""" + + def test_str(self): + """Test string representation.""" + q1 = Qubit(1) + r1 = Resonator(1) + self.assertEqual("Q1", str(q1)) + self.assertEqual("R1", str(r1)) + + def test_to_component(self): + """Test converting string to component object.""" + q1 = to_component("Q1") + self.assertIsInstance(q1, Qubit) + self.assertEqual("Q1", str(q1)) + r1 = to_component("R1") + self.assertIsInstance(r1, Resonator) + self.assertEqual("R1", str(r1)) diff --git a/test/stored_data/test_stored_data.py b/test/stored_data/test_stored_data.py new file mode 100644 index 0000000000..6dce6f555c --- /dev/null +++ b/test/stored_data/test_stored_data.py @@ -0,0 +1,712 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +# pylint: disable=missing-docstring + +"""Test ExperimentData.""" + +import os +from unittest import mock, skipIf +import copy +from random import randrange +import time +import threading +import json +import re + +import numpy as np + +from qiskit.test import QiskitTestCase +from qiskit.test.mock import FakeMelbourne +from qiskit.result import Result +from qiskit.providers import JobV1 as Job +from qiskit.providers import JobStatus +from qiskit.tools.visualization import HAS_MATPLOTLIB +from qiskit_experiments.store_data import StoredDataV1 as StoredData +from qiskit_experiments.store_data import ExperimentServiceV1 +from qiskit_experiments.store_data.exceptions import (ExperimentError, ExperimentEntryNotFound, + ExperimentEntryExists) + + +class TestExperimentData(QiskitTestCase): + """Test the ExperimentData class.""" + + def setUp(self): + super().setUp() + self.backend = FakeMelbourne() + + def test_stored_data_attributes(self): + """Test stored data attributes.""" + attrs = { + "job_ids": ["job1"], + "share_level": "global", + "figure_names": ["figure1"], + "notes": "some notes", + } + exp_data = StoredData( + backend=self.backend, + experiment_type="qiskit_test", + experiment_id="1234", + tags=["tag1", "tag2"], + metadata={"foo": "bar"}, + **attrs, + ) + self.assertEqual(exp_data.backend.name(), self.backend.name()) + self.assertEqual(exp_data.experiment_type, "qiskit_test") + self.assertEqual(exp_data.experiment_id, "1234") + self.assertEqual(exp_data.tags(), ["tag1", "tag2"]) + self.assertEqual(exp_data.metadata(), {"foo": "bar"}) + for key, val in attrs.items(): + self.assertEqual(getattr(exp_data, key), val) + + def test_add_data_dict(self): + """Test add data in dictionary.""" + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + a_dict = {"counts": {"01": 518}} + dicts = [{"counts": {"00": 284}}, {"counts": {"00": 14}}] + + exp_data.add_data(a_dict) + exp_data.add_data(dicts) + self.assertEqual([a_dict] + dicts, exp_data.data()) + + def test_add_data_result(self): + """Test add result data.""" + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + a_result = self._get_job_result(1) + results = [self._get_job_result(2), self._get_job_result(3)] + + expected = [a_result.get_counts()] + for res in results: + expected.extend(res.get_counts()) + + exp_data.add_data(a_result) + exp_data.add_data(results) + self.assertEqual(expected, [sdata["counts"] for sdata in exp_data.data()]) + self.assertIn(a_result.job_id, exp_data.job_ids) + + def test_add_data_result_metadata(self): + """Test add result metadata.""" + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + result1 = self._get_job_result(1, has_metadata=False) + result2 = self._get_job_result(1, has_metadata=True) + + exp_data.add_data(result1) + exp_data.add_data(result2) + self.assertNotIn("metadata", exp_data.data(0)) + self.assertIn("metadata", exp_data.data(1)) + + def test_add_data_job(self): + """Test add job data.""" + a_job = mock.create_autospec(Job, instance=True) + a_job.result.return_value = self._get_job_result(3) + jobs = [] + for _ in range(2): + job = mock.create_autospec(Job, instance=True) + job.result.return_value = self._get_job_result(2) + jobs.append(job) + + expected = a_job.result().get_counts() + for job in jobs: + expected.extend(job.result().get_counts()) + + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data.add_data(a_job) + exp_data.add_data(jobs) + exp_data.block_for_results() + self.assertEqual(expected, [sdata["counts"] for sdata in exp_data.data()]) + self.assertIn(a_job.job_id(), exp_data.job_ids) + + def test_add_data_job_callback(self): + """Test add job data with callback.""" + + def _callback(_exp_data, data_index): + self.assertIsInstance(_exp_data, StoredData) + self.assertEqual( + [dat["counts"] for dat in _exp_data.data()], a_job.result().get_counts() + ) + self.assertEqual(len(a_job.result().results)-1, data_index) + exp_data.add_figures(str.encode("hello world")) + exp_data.add_analysis_results(mock.MagicMock()) + nonlocal called_back + called_back = True + + a_job = mock.create_autospec(Job, instance=True) + a_job.result.return_value = self._get_job_result(2) + + called_back = False + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data.add_data(a_job, post_processing_callback=_callback) + exp_data.block_for_results() + self.assertTrue(called_back) + + def test_add_data_callback(self): + """Test add data with callback.""" + + def _callback(_exp_data, data_index): + self.assertIsInstance(_exp_data, StoredData) + nonlocal called_back_count, expected_data, subtests + expected_data.extend(subtests[called_back_count][1]) + self.assertEqual([dat["counts"] for dat in _exp_data.data()], expected_data) + self.assertEqual(len(_exp_data.data())-1, data_index) + called_back_count += 1 + + a_result = self._get_job_result(1) + results = [self._get_job_result(1), self._get_job_result(1)] + a_dict = {"counts": {"01": 518}} + dicts = [{"counts": {"00": 284}}, {"counts": {"00": 14}}] + + subtests = [ + (a_result, [a_result.get_counts()]), + (results, [res.get_counts() for res in results]), + (a_dict, [a_dict["counts"]]), + (dicts, [dat["counts"] for dat in dicts]), + ] + + called_back_count = 0 + expected_data = [] + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + + for data, _ in subtests: + with self.subTest(data=data): + exp_data.add_data(data, post_processing_callback=_callback) + + self.assertEqual(len(subtests), called_back_count) + + def test_add_data_job_callback_kwargs(self): + """Test add job data with callback and additional arguments.""" + + def _callback(_exp_data, data_index, **kwargs): + self.assertIsInstance(_exp_data, StoredData) + self.assertEqual(len(_exp_data.data())-1, data_index) + self.assertEqual({"foo": callback_kwargs}, kwargs) + nonlocal called_back + called_back = True + + a_job = mock.create_autospec(Job, instance=True) + a_job.result.return_value = self._get_job_result(2) + + called_back = False + callback_kwargs = "foo" + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data.add_data(a_job, _callback, foo=callback_kwargs) + exp_data.block_for_results() + self.assertTrue(called_back) + + def test_get_data(self): + """Test getting data.""" + data1 = [] + for _ in range(5): + data1.append({"counts": {"00": randrange(1024)}}) + results = self._get_job_result(3) + + exp_data = StoredData(experiment_type="qiskit_test") + exp_data.add_data(data1) + exp_data.add_data(results) + self.assertEqual(data1[1], exp_data.data(1)) + self.assertEqual(data1[2:4], exp_data.data(slice(2, 4))) + self.assertEqual( + results.get_counts(), [sdata["counts"] for sdata in exp_data.data(results.job_id)] + ) + + def test_add_figure(self): + """Test adding a new figure.""" + hello_bytes = str.encode("hello world") + file_name = "hello_world.svg" + self.addCleanup(os.remove, file_name) + with open(file_name, "wb") as file: + file.write(hello_bytes) + + sub_tests = [ + ("file name", file_name, None), + ("file bytes", hello_bytes, None), + ("new name", hello_bytes, "hello_again.svg"), + ] + + for name, figure, figure_name in sub_tests: + with self.subTest(name=name): + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + fn = exp_data.add_figures(figure, figure_name) + self.assertEqual(hello_bytes, exp_data.figure(fn)) + + @skipIf(not HAS_MATPLOTLIB, "matplotlib not available.") + def test_add_figure_plot(self): + """Test adding a matplotlib figure.""" + import matplotlib.pyplot as plt + + figure, ax = plt.subplots() + ax.plot([1, 2, 3]) + + service = self._set_mock_service() + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data.add_figures(figure, save_figure=True) + self.assertEqual(figure, exp_data.figure(0)) + service.create_figure.assert_called_once() + _, kwargs = service.create_figure.call_args + self.assertIsInstance(kwargs["figure"], bytes) + + def test_add_figures(self): + """Test adding multiple new figures.""" + hello_bytes = [str.encode("hello world"), str.encode("hello friend")] + file_names = ["hello_world.svg", "hello_friend.svg"] + for idx, fn in enumerate(file_names): + self.addCleanup(os.remove, fn) + with open(fn, "wb") as file: + file.write(hello_bytes[idx]) + + sub_tests = [ + ("file names", file_names, None), + ("file bytes", hello_bytes, None), + ("new names", hello_bytes, ["hello1.svg", "hello2.svg"]), + ] + + for name, figures, figure_names in sub_tests: + with self.subTest(name=name): + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + added_names = exp_data.add_figures(figures, figure_names) + for idx, added_fn in enumerate(added_names): + self.assertEqual(hello_bytes[idx], exp_data.figure(added_fn)) + + def test_add_figure_overwrite(self): + """Test updating an existing figure.""" + hello_bytes = str.encode("hello world") + friend_bytes = str.encode("hello friend!") + + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + fn = exp_data.add_figures(hello_bytes) + with self.assertRaises(ExperimentEntryExists): + exp_data.add_figures(friend_bytes, fn) + + exp_data.add_figures(friend_bytes, fn, overwrite=True) + self.assertEqual(friend_bytes, exp_data.figure(fn)) + + def test_add_figure_save(self): + """Test saving a figure in the database.""" + hello_bytes = str.encode("hello world") + service = self._set_mock_service() + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data.add_figures(hello_bytes, save_figure=True) + service.create_figure.assert_called_once() + _, kwargs = service.create_figure.call_args + self.assertEqual(kwargs["figure"], hello_bytes) + self.assertEqual(kwargs["experiment_id"], exp_data.experiment_id) + + def test_add_figure_bad_input(self): + """Test adding figures with bad input.""" + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + self.assertRaises(ValueError, exp_data.add_figures, ["foo", "bar"], ["name"]) + + def test_get_figure(self): + """Test getting figure.""" + exp_data = StoredData(experiment_type="qiskit_test") + figure_template = "hello world {}" + name_template = "figure_{}" + for idx in range(3): + exp_data.add_figures( + str.encode(figure_template.format(idx)), figure_names=name_template.format(idx) + ) + idx = randrange(3) + expected_figure = str.encode(figure_template.format(idx)) + self.assertEqual(expected_figure, exp_data.figure(name_template.format(idx))) + self.assertEqual(expected_figure, exp_data.figure(idx)) + + file_name = "hello_world.svg" + self.addCleanup(os.remove, file_name) + exp_data.figure(idx, file_name) + with open(file_name, "rb") as file: + self.assertEqual(expected_figure, file.read()) + + def test_delete_figure(self): + """Test deleting a figure.""" + exp_data = StoredData(experiment_type="qiskit_test") + id_template = "figure_{}" + for idx in range(3): + exp_data.add_figures(str.encode("hello world"), id_template.format(idx)) + + sub_tests = [(1, id_template.format(1)), (id_template.format(2), id_template.format(2))] + + for del_key, figure_name in sub_tests: + with self.subTest(del_key=del_key): + exp_data.delete_figure(del_key) + self.assertRaises(ExperimentEntryNotFound, exp_data.figure, figure_name) + + def test_delayed_backend(self): + """Test initializing experiment data without a backend.""" + exp_data = StoredData(experiment_type="qiskit_test") + self.assertIsNone(exp_data.backend) + self.assertIsNone(exp_data.service) + exp_data.save() + a_job = mock.create_autospec(Job, instance=True) + exp_data.add_data(a_job) + self.assertIsNotNone(exp_data.backend) + self.assertIsNotNone(exp_data.service) + + def test_different_backend(self): + """Test setting a different backend.""" + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + a_job = mock.create_autospec(Job, instance=True) + self.assertNotEqual(exp_data.backend, a_job.backend()) + with self.assertLogs("qiskit_experiments", "WARNING"): + exp_data.add_data(a_job) + + def test_add_get_analysis_result(self): + """Test adding and getting analysis results.""" + exp_data = StoredData(experiment_type="qiskit_test") + results = [] + for idx in range(5): + res = mock.MagicMock() + res.result_id = idx + results.append(res) + exp_data.add_analysis_results(res) + + self.assertEqual(results, exp_data.analysis_result()) + self.assertEqual(results[1], exp_data.analysis_result(1)) + self.assertEqual(results[2:4], exp_data.analysis_result(slice(2, 4))) + self.assertEqual(results[4], exp_data.analysis_result(results[4].result_id)) + + def test_add_get_analysis_results(self): + """Test adding and getting a list of analysis results.""" + exp_data = StoredData(experiment_type="qiskit_test") + results = [] + for idx in range(5): + res = mock.MagicMock() + res.result_id = idx + results.append(res) + exp_data.add_analysis_results(results) + + self.assertEqual(results, exp_data.analysis_result()) + + def test_delete_analysis_result(self): + """Test deleting analysis result.""" + exp_data = StoredData(experiment_type="qiskit_test") + id_template = "result_{}" + for idx in range(3): + res = mock.MagicMock() + res.result_id = id_template.format(idx) + exp_data.add_analysis_results(res) + + subtests = [(0, id_template.format(0)), (id_template.format(2), id_template.format(2))] + for del_key, res_id in subtests: + with self.subTest(del_key=del_key): + exp_data.delete_analysis_result(del_key) + self.assertRaises(ExperimentEntryNotFound, exp_data.analysis_result, res_id) + + def test_save(self): + """Test saving experiment data.""" + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + service = mock.create_autospec(ExperimentServiceV1, instance=True) + exp_data.save(service=service) + service.create_experiment.assert_called_once() + _, kwargs = service.create_experiment.call_args + self.assertEqual(exp_data.experiment_id, kwargs["experiment_id"]) + exp_data.save(service=service) + service.update_experiment.assert_called_once() + _, kwargs = service.update_experiment.call_args + self.assertEqual(exp_data.experiment_id, kwargs["experiment_id"]) + + def test_save_all(self): + """Test saving all experiment related data.""" + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + service = mock.create_autospec(ExperimentServiceV1, instance=True) + exp_data.add_figures(str.encode("hello world")) + analysis_result = mock.MagicMock() + exp_data.add_analysis_results(analysis_result) + exp_data.save_all(service=service) + service.create_experiment.assert_called_once() + service.create_figure.assert_called_once() + analysis_result.save.assert_called_once() + + def test_save_all_delete(self): + """Test saving all deletion.""" + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + service = mock.create_autospec(ExperimentServiceV1, instance=True) + exp_data.add_figures(str.encode("hello world")) + exp_data.add_analysis_results(mock.MagicMock()) + exp_data.delete_analysis_result(0) + exp_data.delete_figure(0) + + exp_data.save_all(service=service) + service.create_experiment.assert_called_once() + service.delete_figure.assert_called_once() + service.delete_analysis_result.assert_called_once() + + def test_set_service_backend(self): + """Test setting service via backend.""" + mock_service = self._set_mock_service() + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + self.assertEqual(mock_service, exp_data.service) + + def test_set_service_job(self): + """Test setting service via adding a job.""" + mock_service = self._set_mock_service() + job = mock.create_autospec(Job, instance=True) + job.backend.return_value = self.backend + exp_data = StoredData(experiment_type="qiskit_test") + self.assertIsNone(exp_data.service) + exp_data.add_data(job) + self.assertEqual(mock_service, exp_data.service) + + def test_set_service_direct(self): + """Test setting service directly.""" + exp_data = StoredData(experiment_type="qiskit_test") + self.assertIsNone(exp_data.service) + mock_service = mock.MagicMock() + exp_data.service = mock_service + self.assertEqual(mock_service, exp_data.service) + + with self.assertRaises(ExperimentError): + exp_data.service = mock_service + + def test_set_service_save(self): + """Test setting service when saving.""" + orig_service = self._set_mock_service() + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + new_service = mock.create_autospec(ExperimentServiceV1, instance=True) + exp_data.save(service=new_service) + new_service.create_experiment.assert_called() + orig_service.create_experiment.assert_not_called() + + def test_new_backend_has_service(self): + """Test changing backend doesn't change existing service.""" + orig_service = self._set_mock_service() + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + self.assertEqual(orig_service, exp_data.service) + + job = mock.create_autospec(Job, instance=True) + new_service = self._set_mock_service() + self.assertNotEqual(orig_service, new_service) + job.backend.return_value = self.backend + exp_data.add_data(job) + self.assertEqual(orig_service, exp_data.service) + + def test_auto_save(self): + """Test auto save.""" + service = self._set_mock_service() + exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data.auto_save = True + + mock_result = mock.MagicMock() + exp_data.save() + + subtests = [ + # update function, update parameters, service called + (exp_data.add_analysis_results, (mock_result,), mock_result.save), + (exp_data.add_figures, (str.encode("hello world"),), service.create_figure), + (exp_data.delete_figure, (0,), service.delete_figure), + (exp_data.delete_analysis_result, (0,), service.delete_analysis_result), + (exp_data.update_tags, (["foo"],), None), + (exp_data.update_metadata, ({"foo": "bar"},), None), + (setattr, (exp_data, "notes", "foo"), None), + (setattr, (exp_data, "share_level", "hub"), None), + ] + + for func, params, called in subtests: + with self.subTest(func=func): + func(*params) + service.update_experiment.assert_called_once() + if called: + called.assert_called_once() + service.reset_mock() + + def test_status_job_pending(self): + """Test experiment status when job is pending.""" + job1 = mock.create_autospec(Job, instance=True) + job1.result.return_value = self._get_job_result(3) + job1.status.return_value = JobStatus.DONE + + event = threading.Event() + job2 = mock.create_autospec(Job, instance=True) + job2.result = lambda *args, **kwargs: event.wait() + job2.status.return_value = JobStatus.RUNNING + self.addCleanup(event.set) + + exp_data = StoredData(experiment_type="qiskit_test") + exp_data.add_data(job1) + exp_data.add_data(job2, lambda *args, **kwargs: event.wait()) + self.assertEqual("RUNNING", exp_data.status()) + + def test_status_job_error(self): + """Test experiment status when job failed.""" + job1 = mock.create_autospec(Job, instance=True) + job1.result.return_value = self._get_job_result(3) + job1.status.return_value = JobStatus.DONE + + job2 = mock.create_autospec(Job, instance=True) + job2.status.return_value = JobStatus.ERROR + + exp_data = StoredData(experiment_type="qiskit_test") + exp_data.add_data([job1, job2]) + self.assertEqual("ERROR", exp_data.status()) + + def test_status_post_processing(self): + """Test experiment status during post processing.""" + job = mock.create_autospec(Job, instance=True) + job.result.return_value = self._get_job_result(3) + + event = threading.Event() + self.addCleanup(event.set) + + exp_data = StoredData(experiment_type="qiskit_test") + exp_data.add_data(job) + exp_data.add_data(job, lambda *args, **kwargs: event.wait()) + self.assertEqual("POST_PROCESSING", exp_data.status()) + + def test_status_post_processing_error(self): + """Test experiment status when post processing failed.""" + + def _post_processing(*args, **kwargs): + raise ValueError("Kaboom!") + + job = mock.create_autospec(Job, instance=True) + job.result.return_value = self._get_job_result(3) + + exp_data = StoredData(experiment_type="qiskit_test") + exp_data.add_data(job) + exp_data.add_data(job, _post_processing) + exp_data.block_for_results() + self.assertEqual("ERROR", exp_data.status()) + + def test_status_done(self): + """Test experiment status when all jobs are done.""" + job = mock.create_autospec(Job, instance=True) + job.result.return_value = self._get_job_result(3) + exp_data = StoredData(experiment_type="qiskit_test") + exp_data.add_data(job) + exp_data.add_data(job, lambda *args, **kwargs: time.sleep(1)) + exp_data.block_for_results() + self.assertEqual("DONE", exp_data.status()) + + def test_update_tags(self): + """Test updating experiment tags.""" + exp_data = StoredData(experiment_type="qiskit_test", tags=["foo"]) + self.assertEqual(["foo"], exp_data.tags()) + exp_data.update_tags(["bar"]) + self.assertEqual(["bar"], exp_data.tags()) + + def test_update_metadata(self): + """Test updating experiment metadata.""" + exp_data = StoredData(experiment_type="qiskit_test", metadata={"foo": "bar"}) + self.assertEqual({"foo": "bar"}, exp_data.metadata()) + exp_data.update_metadata({"bar": "foo"}) + self.assertEqual({"bar": "foo"}, exp_data.metadata()) + + def test_cancel_jobs(self): + """Test canceling experiment jobs.""" + exp_data = StoredData(experiment_type="qiskit_test") + event = threading.Event() + self.addCleanup(event.set) + job = mock.create_autospec(Job, instance=True) + job.result = lambda *args, **kwargs: event.wait() + exp_data.add_data(job) + exp_data.cancel_jobs() + job.cancel.assert_called_once() + + def test_metadata_serialization(self): + """Test experiment metadata serialization.""" + metadata = {"complex": 2 + 3j, "numpy": np.zeros(2)} + exp_data = StoredData(experiment_type="qiskit_test", metadata=metadata) + serialized = exp_data.serialize_metadata() + self.assertIsInstance(serialized, str) + self.assertTrue(json.loads(serialized)) + + deserialized = StoredData.deserialize_metadata(serialized) + self.assertEqual(metadata["complex"], deserialized["complex"]) + self.assertEqual(metadata["numpy"].all(), deserialized["numpy"].all()) + + def test_errors(self): + """Test getting experiment error message.""" + + def _post_processing(*args, **kwargs): # pylint: disable=unused-argument + raise ValueError("Kaboom!") + + job1 = mock.create_autospec(Job, instance=True) + job1.job_id.return_value = "1234" + + job2 = mock.create_autospec(Job, instance=True) + job2.status.return_value = JobStatus.ERROR + job2.job_id.return_value = "5678" + + exp_data = StoredData(experiment_type="qiskit_test") + exp_data.add_data(job1, _post_processing) + exp_data.add_data(job2) + exp_data.block_for_results() + self.assertEqual("ERROR", exp_data.status()) + self.assertTrue(re.match(r".*1234.*Kaboom!.*5678", exp_data.errors(), re.DOTALL)) + + def test_source(self): + """Test getting experiment source.""" + exp_data = StoredData(experiment_type="qiskit_test") + source_vals = "\n".join([str(val) for val in exp_data.source.values()]) + self.assertIn("StoredDataV1", source_vals) + self.assertIn("qiskit-terra", source_vals) + + def test_block_for_jobs(self): + """Test blocking for jobs.""" + + def _sleeper(*args, **kwargs): # pylint: disable=unused-argument + time.sleep(2) + nonlocal sleep_count + sleep_count += 1 + return self._get_job_result(1) + + sleep_count = 0 + job = mock.create_autospec(Job, instance=True) + job.result = _sleeper + exp_data = StoredData(experiment_type="qiskit_test") + exp_data.add_data(job, _sleeper) + exp_data.block_for_results() + self.assertEqual(2, sleep_count) + + def test_additional_attr(self): + """Test additional experiment attributes.""" + exp_data = StoredData(experiment_type="qiskit_test", foo="foo") + self.assertEqual("foo", exp_data.foo) + + def test_str(self): + """Test the string representation.""" + exp_data = StoredData(experiment_type="qiskit_test") + exp_data.add_data(self._get_job_result(1)) + result = mock.MagicMock() + exp_data.add_analysis_results(result) + exp_data_str = str(exp_data) + self.assertIn(exp_data.experiment_type, exp_data_str) + self.assertIn(exp_data.experiment_id, exp_data_str) + self.assertIn(str(result), exp_data_str) + + def _get_job_result(self, circ_count, has_metadata=False): + """Return a job result with random counts.""" + job_result = { + "backend_name": self.backend.name(), + "backend_version": "1.1.1", + "qobj_id": "1234", + "job_id": "some_job_id", + "success": True, + "results": [], + } + circ_result_template = {"shots": 1024, "success": True, "data": {}} + + for _ in range(circ_count): + counts = randrange(1024) + circ_result = copy.copy(circ_result_template) + circ_result["data"] = {"counts": {"0x0": counts, "0x3": 1024 - counts}} + if has_metadata: + circ_result["header"] = {"metadata": {"meas_basis": "pauli"}} + job_result["results"].append(circ_result) + + return Result.from_dict(job_result) + + def _set_mock_service(self): + """Add a mock service to the backend.""" + mock_provider = mock.MagicMock() + self.backend._provider = mock_provider + mock_service = mock.create_autospec(ExperimentServiceV1, instance=True) + mock_provider.service.return_value = mock_service + return mock_service From 75067595d11109ddbb6114a31ffcd168d5dd014d Mon Sep 17 00:00:00 2001 From: jessieyu Date: Wed, 16 Jun 2021 20:50:24 -0400 Subject: [PATCH 02/20] run black --- .../store_data/analysis_result.py | 34 ++--- .../store_data/experiment_service.py | 130 +++++++++--------- qiskit_experiments/store_data/stored_data.py | 92 ++++++------- qiskit_experiments/store_data/utils.py | 2 +- test/stored_data/test_stored_data.py | 20 +-- 5 files changed, 139 insertions(+), 139 deletions(-) diff --git a/qiskit_experiments/store_data/analysis_result.py b/qiskit_experiments/store_data/analysis_result.py index cf3e17530e..c27aafe5c6 100644 --- a/qiskit_experiments/store_data/analysis_result.py +++ b/qiskit_experiments/store_data/analysis_result.py @@ -64,17 +64,17 @@ class AnalysisResultV1(AnalysisResult): _extra_data = {} def __init__( - self, - result_data: Dict, - result_type: str, - device_components: List[Union[DeviceComponent, str]], - experiment_id: str, - result_id: Optional[str] = None, - quality: Optional[str] = None, - verified: bool = False, - tags: Optional[List[str]] = None, - service: Optional["ExperimentServiceV1"] = None, - **kwargs, + self, + result_data: Dict, + result_type: str, + device_components: List[Union[DeviceComponent, str]], + experiment_id: str, + result_id: Optional[str] = None, + quality: Optional[str] = None, + verified: bool = False, + tags: Optional[List[str]] = None, + service: Optional["ExperimentServiceV1"] = None, + **kwargs, ): """AnalysisResult constructor. @@ -149,12 +149,12 @@ def deserialize_data(cls, data: str) -> Dict: @classmethod def from_data( - cls, - result_data: Dict, - result_type: str, - device_components: List[Union[DeviceComponent, str]], - experiment_id: str, - **kwargs, + cls, + result_data: Dict, + result_type: str, + device_components: List[Union[DeviceComponent, str]], + experiment_id: str, + **kwargs, ) -> "AnalysisResultV1": """Reconstruct the analysis result from input data. diff --git a/qiskit_experiments/store_data/experiment_service.py b/qiskit_experiments/store_data/experiment_service.py index 71fa8d0ab1..0cf172ffca 100644 --- a/qiskit_experiments/store_data/experiment_service.py +++ b/qiskit_experiments/store_data/experiment_service.py @@ -17,7 +17,7 @@ from .device_component import DeviceComponent -T = TypeVar('T') +T = TypeVar("T") class ExperimentService: @@ -61,15 +61,15 @@ def _default_options(cls) -> Dict: @abstractmethod def create_experiment( - self, - experiment_type: str, - backend_name: str, - metadata: Optional[Dict] = None, - experiment_id: Optional[str] = None, - job_ids: Optional[List[str]] = None, - tags: Optional[List[str]] = None, - notes: Optional[str] = None, - **kwargs: Any, + self, + experiment_type: str, + backend_name: str, + metadata: Optional[Dict] = None, + experiment_id: Optional[str] = None, + job_ids: Optional[List[str]] = None, + tags: Optional[List[str]] = None, + notes: Optional[str] = None, + **kwargs: Any, ) -> str: """Create a new experiment in the database. @@ -94,13 +94,13 @@ def create_experiment( @abstractmethod def update_experiment( - self, - experiment_id: str, - metadata: Optional[Dict] = None, - job_ids: Optional[List[str]] = None, - notes: Optional[str] = None, - tags: Optional[List[str]] = None, - **kwargs: Any, + self, + experiment_id: str, + metadata: Optional[Dict] = None, + job_ids: Optional[List[str]] = None, + notes: Optional[str] = None, + tags: Optional[List[str]] = None, + **kwargs: Any, ) -> None: """Update an existing experiment. @@ -119,9 +119,7 @@ def update_experiment( @abstractmethod def experiment( - self, - experiment_id: str, - experiment_class: Optional[Type[T]] = None + self, experiment_id: str, experiment_class: Optional[Type[T]] = None ) -> Union[Dict, T]: """Retrieve a previously stored experiment. @@ -142,15 +140,15 @@ def experiment( @abstractmethod def experiments( - self, - limit: Optional[int] = 10, - experiment_class: Optional[Type[T]] = None, - device_components: Optional[Union[str, DeviceComponent]] = None, - experiment_type: Optional[str] = None, - backend_name: Optional[str] = None, - tags: Optional[List[str]] = None, - tags_operator: Optional[str] = "OR", - **filters: Any, + self, + limit: Optional[int] = 10, + experiment_class: Optional[Type[T]] = None, + device_components: Optional[Union[str, DeviceComponent]] = None, + experiment_type: Optional[str] = None, + backend_name: Optional[str] = None, + tags: Optional[List[str]] = None, + tags_operator: Optional[str] = "OR", + **filters: Any, ) -> List[Union[Dict, T]]: """Retrieve all experiment data, with optional filtering. @@ -193,16 +191,16 @@ def delete_experiment(self, experiment_id: str) -> None: @abstractmethod def create_analysis_result( - self, - experiment_id: str, - data: Dict, - result_type: str, - device_components: Optional[Union[str, DeviceComponent]] = None, - tags: Optional[List[str]] = None, - quality: Optional[str] = None, - verified: bool = False, - result_id: Optional[str] = None, - **kwargs: Any, + self, + experiment_id: str, + data: Dict, + result_type: str, + device_components: Optional[Union[str, DeviceComponent]] = None, + tags: Optional[List[str]] = None, + quality: Optional[str] = None, + verified: bool = False, + result_id: Optional[str] = None, + **kwargs: Any, ) -> str: """Create a new analysis result in the database. @@ -228,13 +226,13 @@ def create_analysis_result( @abstractmethod def update_analysis_result( - self, - result_id: str, - data: Optional[Dict] = None, - tags: Optional[List[str]] = None, - quality: Optional[str] = None, - verified: bool = None, - **kwargs: Any, + self, + result_id: str, + data: Optional[Dict] = None, + tags: Optional[List[str]] = None, + quality: Optional[str] = None, + verified: bool = None, + **kwargs: Any, ) -> None: """Update an existing analysis result. @@ -253,9 +251,7 @@ def update_analysis_result( @abstractmethod def analysis_result( - self, - result_id: str, - result_class: Optional[Type[T]] = None + self, result_id: str, result_class: Optional[Type[T]] = None ) -> Union[Dict, T]: """Retrieve a previously stored experiment. @@ -275,18 +271,18 @@ def analysis_result( @abstractmethod def analysis_results( - self, - limit: Optional[int] = 10, - result_class: Optional[Type[T]] = None, - device_components: Optional[Union[str, DeviceComponent]] = None, - experiment_id: Optional[str] = None, - result_type: Optional[str] = None, - backend_name: Optional[str] = None, - quality: Optional[str] = None, - verified: Optional[bool] = None, - tags: Optional[List[str]] = None, - tags_operator: Optional[str] = "OR", - **filters: Any, + self, + limit: Optional[int] = 10, + result_class: Optional[Type[T]] = None, + device_components: Optional[Union[str, DeviceComponent]] = None, + experiment_id: Optional[str] = None, + result_type: Optional[str] = None, + backend_name: Optional[str] = None, + quality: Optional[str] = None, + verified: Optional[bool] = None, + tags: Optional[List[str]] = None, + tags_operator: Optional[str] = "OR", + **filters: Any, ) -> List[Union[Dict, T]]: """Retrieve all analysis results, with optional filtering. @@ -332,7 +328,7 @@ def delete_analysis_result(self, result_id: str) -> None: @abstractmethod def create_figure( - self, experiment_id: str, figure: Union[str, bytes], figure_name: Optional[str] + self, experiment_id: str, figure: Union[str, bytes], figure_name: Optional[str] ) -> Tuple[str, int]: """Store a new figure in the database. @@ -352,7 +348,7 @@ def create_figure( @abstractmethod def update_figure( - self, experiment_id: str, figure: Union[str, bytes], figure_name: str + self, experiment_id: str, figure: Union[str, bytes], figure_name: str ) -> Tuple[str, int]: """Update an existing figure. @@ -371,7 +367,7 @@ def update_figure( @abstractmethod def figure( - self, experiment_id: str, figure_name: str, file_name: Optional[str] = None + self, experiment_id: str, figure_name: str, file_name: Optional[str] = None ) -> Union[int, bytes]: """Retrieve an existing figure. @@ -392,9 +388,9 @@ def figure( @abstractmethod def delete_figure( - self, - experiment_id: str, - figure_name: str, + self, + experiment_id: str, + figure_name: str, ) -> None: """Delete an existing figure. diff --git a/qiskit_experiments/store_data/stored_data.py b/qiskit_experiments/store_data/stored_data.py index 2c40cce9bc..0903d5fc51 100644 --- a/qiskit_experiments/store_data/stored_data.py +++ b/qiskit_experiments/store_data/stored_data.py @@ -87,17 +87,17 @@ class StoredDataV1(StoredData): _json_decoder = NumpyDecoder def __init__( - self, - experiment_type: str, - backend: Optional[Union[Backend, BaseBackend]] = None, - experiment_id: Optional[str] = None, - tags: Optional[List[str]] = None, - job_ids: Optional[List[str]] = None, - share_level: Optional[str] = None, - metadata: Optional[Dict] = None, - figure_names: Optional[List[str]] = None, - notes: Optional[str] = None, - **kwargs, + self, + experiment_type: str, + backend: Optional[Union[Backend, BaseBackend]] = None, + experiment_id: Optional[str] = None, + tags: Optional[List[str]] = None, + job_ids: Optional[List[str]] = None, + share_level: Optional[str] = None, + metadata: Optional[Dict] = None, + figure_names: Optional[List[str]] = None, + notes: Optional[str] = None, + **kwargs, ): """Initializes the StoredDataV1 instance. @@ -166,10 +166,10 @@ def _set_service_from_backend(self, backend: Union[Backend, BaseBackend]) -> Non self.auto_save = self._service.option("auto_save") def add_data( - self, - data: Union[Result, List[Result], Job, List[Job], Dict, List[Dict]], - post_processing_callback: Optional[Callable] = None, - **kwargs: Any, + self, + data: Union[Result, List[Result], Job, List[Job], Dict, List[Dict]], + post_processing_callback: Optional[Callable] = None, + **kwargs: Any, ) -> None: """Add experiment data. @@ -245,13 +245,13 @@ def add_data( raise TypeError(f"Invalid data type {type(data)}.") if post_processing_callback is not None: - post_processing_callback(self, len(self._data)-1, **kwargs) + post_processing_callback(self, len(self._data) - 1, **kwargs) def _wait_for_job( - self, - job: Union[Job, BaseJob], - job_done_callback: Optional[Callable] = None, - **kwargs: Any, + self, + job: Union[Job, BaseJob], + job_done_callback: Optional[Callable] = None, + **kwargs: Any, ) -> None: """Wait for a job to finish. @@ -266,7 +266,7 @@ def _wait_for_job( with self._data.lock: # Hold the lock so we add the block of results together. self._add_result_data(job_result) - data_index = len(self._data)-1 + data_index = len(self._data) - 1 except JobError as err: LOG.warning("Job %s failed: %s", job.job_id(), str(err)) return @@ -340,12 +340,12 @@ def data(self, index: Optional[Union[int, slice, str]] = None) -> Union[Dict, Li @auto_save def add_figures( - self, - figures: Union[List[Union[str, bytes, "pyplot.Figure"]], str, bytes, "pyplot.Figure"], - figure_names: Optional[Union[List[str], str]] = None, - overwrite: bool = False, - save_figure: Optional[bool] = None, - service: Optional["ExperimentServiceV1"] = None, + self, + figures: Union[List[Union[str, bytes, "pyplot.Figure"]], str, bytes, "pyplot.Figure"], + figure_names: Optional[Union[List[str], str]] = None, + overwrite: bool = False, + save_figure: Optional[bool] = None, + service: Optional["ExperimentServiceV1"] = None, ) -> Union[str, List[str]]: """Add the experiment figure. @@ -370,9 +370,9 @@ def add_figures( ValueError: If an input parameter has an invalid value. """ if ( - isinstance(figures, list) - and figure_names is not None - and (not isinstance(figure_names, list) or len(figures) != len(figure_names)) + isinstance(figures, list) + and figure_names is not None + and (not isinstance(figure_names, list) or len(figures) != len(figure_names)) ): raise ValueError( "The parameter figure_names must be None or a list of " @@ -436,9 +436,9 @@ def add_figures( @auto_save def delete_figure( - self, - figure_key: Union[str, int], - service: Optional["ExperimentServiceV1"] = None, + self, + figure_key: Union[str, int], + service: Optional["ExperimentServiceV1"] = None, ) -> str: """Add the experiment figure. @@ -470,7 +470,7 @@ def delete_figure( return figure_key def figure( - self, figure_key: Union[str, int], file_name: Optional[str] = None + self, figure_key: Union[str, int], file_name: Optional[str] = None ) -> Union[int, bytes]: """Retrieve the specified experiment figure. @@ -507,9 +507,9 @@ def figure( @auto_save def add_analysis_results( - self, - results: Union[AnalysisResult, List[AnalysisResult]], - service: "ExperimentServiceV1" = None, + self, + results: Union[AnalysisResult, List[AnalysisResult]], + service: "ExperimentServiceV1" = None, ) -> None: """Save the analysis result. @@ -534,7 +534,7 @@ def add_analysis_results( @auto_save def delete_analysis_result( - self, result_key: Union[int, str], service: "ExperimentServiceV1" = None + self, result_key: Union[int, str], service: "ExperimentServiceV1" = None ) -> str: """Delete the analysis result. @@ -566,7 +566,7 @@ def delete_analysis_result( return result_key def analysis_result( - self, index: Optional[Union[int, slice, str]] = None, refresh: bool = False + self, index: Optional[Union[int, slice, str]] = None, refresh: bool = False ) -> Union[AnalysisResult, List[AnalysisResult]]: """Return analysis results associated with this experiment. @@ -731,11 +731,11 @@ def deserialize_metadata(cls, data: str) -> Any: @classmethod def from_data( - cls, - experiment_type: str, - experiment_id: str, - metadata: Optional[Dict] = None, - **kwargs, + cls, + experiment_type: str, + experiment_id: str, + metadata: Optional[Dict] = None, + **kwargs, ) -> "StoredDataV1": """Reconstruct a StoredData using the input data. @@ -792,8 +792,8 @@ def status(self) -> str: Data processing status. """ if all( - len(container) == 0 - for container in [self._data, self._jobs, self._figures, self._analysis_results] + len(container) == 0 + for container in [self._data, self._jobs, self._figures, self._analysis_results] ): return "INITIALIZING" diff --git a/qiskit_experiments/store_data/utils.py b/qiskit_experiments/store_data/utils.py index 66a0b6f6e1..201a4edd8e 100644 --- a/qiskit_experiments/store_data/utils.py +++ b/qiskit_experiments/store_data/utils.py @@ -93,7 +93,7 @@ def plot_to_svg_bytes(figure: "pyplot.Figure") -> bytes: def save_data( - is_new: bool, new_func: Callable, update_func: Callable, new_data: Dict, update_data: Dict + is_new: bool, new_func: Callable, update_func: Callable, new_data: Dict, update_data: Dict ) -> Tuple[bool, Any]: """Save data in the database. diff --git a/test/stored_data/test_stored_data.py b/test/stored_data/test_stored_data.py index 6dce6f555c..f7c18ebcd4 100644 --- a/test/stored_data/test_stored_data.py +++ b/test/stored_data/test_stored_data.py @@ -22,6 +22,7 @@ import threading import json import re +import uuid import numpy as np @@ -33,8 +34,11 @@ from qiskit.tools.visualization import HAS_MATPLOTLIB from qiskit_experiments.store_data import StoredDataV1 as StoredData from qiskit_experiments.store_data import ExperimentServiceV1 -from qiskit_experiments.store_data.exceptions import (ExperimentError, ExperimentEntryNotFound, - ExperimentEntryExists) +from qiskit_experiments.store_data.exceptions import ( + ExperimentError, + ExperimentEntryNotFound, + ExperimentEntryExists, +) class TestExperimentData(QiskitTestCase): @@ -133,7 +137,7 @@ def _callback(_exp_data, data_index): self.assertEqual( [dat["counts"] for dat in _exp_data.data()], a_job.result().get_counts() ) - self.assertEqual(len(a_job.result().results)-1, data_index) + self.assertEqual(len(a_job.result().results) - 1, data_index) exp_data.add_figures(str.encode("hello world")) exp_data.add_analysis_results(mock.MagicMock()) nonlocal called_back @@ -156,7 +160,7 @@ def _callback(_exp_data, data_index): nonlocal called_back_count, expected_data, subtests expected_data.extend(subtests[called_back_count][1]) self.assertEqual([dat["counts"] for dat in _exp_data.data()], expected_data) - self.assertEqual(len(_exp_data.data())-1, data_index) + self.assertEqual(len(_exp_data.data()) - 1, data_index) called_back_count += 1 a_result = self._get_job_result(1) @@ -186,7 +190,7 @@ def test_add_data_job_callback_kwargs(self): def _callback(_exp_data, data_index, **kwargs): self.assertIsInstance(_exp_data, StoredData) - self.assertEqual(len(_exp_data.data())-1, data_index) + self.assertEqual(len(_exp_data.data()) - 1, data_index) self.assertEqual({"foo": callback_kwargs}, kwargs) nonlocal called_back called_back = True @@ -220,7 +224,7 @@ def test_get_data(self): def test_add_figure(self): """Test adding a new figure.""" hello_bytes = str.encode("hello world") - file_name = "hello_world.svg" + file_name = uuid.uuid4().hex self.addCleanup(os.remove, file_name) with open(file_name, "wb") as file: file.write(hello_bytes) @@ -256,7 +260,7 @@ def test_add_figure_plot(self): def test_add_figures(self): """Test adding multiple new figures.""" hello_bytes = [str.encode("hello world"), str.encode("hello friend")] - file_names = ["hello_world.svg", "hello_friend.svg"] + file_names = [uuid.uuid4().hex, uuid.uuid4().hex] for idx, fn in enumerate(file_names): self.addCleanup(os.remove, fn) with open(fn, "wb") as file: @@ -318,7 +322,7 @@ def test_get_figure(self): self.assertEqual(expected_figure, exp_data.figure(name_template.format(idx))) self.assertEqual(expected_figure, exp_data.figure(idx)) - file_name = "hello_world.svg" + file_name = uuid.uuid4().hex self.addCleanup(os.remove, file_name) exp_data.figure(idx, file_name) with open(file_name, "rb") as file: From 4503267e856394791046cebd1342f56de0b71ffb Mon Sep 17 00:00:00 2001 From: jessieyu Date: Wed, 16 Jun 2021 21:17:28 -0400 Subject: [PATCH 03/20] convert service exception to log --- qiskit_experiments/store_data/stored_data.py | 34 +++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/qiskit_experiments/store_data/stored_data.py b/qiskit_experiments/store_data/stored_data.py index 0903d5fc51..0a33c8fc51 100644 --- a/qiskit_experiments/store_data/stored_data.py +++ b/qiskit_experiments/store_data/stored_data.py @@ -57,6 +57,15 @@ def _wrapped(self, *args, **kwargs): return _wrapped +@contextlib.contextmanager +def service_exception_to_warning(): + """Convert an exception raised by experiment service to a warning.""" + try: + yield + except Exception: # pylint: disable=broad-except + LOG.warning("Experiment service operation failed: %s", traceback.format_exc()) + + class StoredData: """Base common type for all versioned StoredData classes. @@ -114,9 +123,6 @@ def __init__( figure_names: Name of figures associated with this experiment. notes: Freeform notes about the experiment. **kwargs: Additional experiment attributes. - - Raises: - ExperimentError: If an input argument is invalid. """ metadata = metadata or {} self._metadata = copy.deepcopy(metadata) @@ -463,7 +469,7 @@ def delete_figure( service = service or self._service if service and self.auto_save: - with contextlib.suppress(ExperimentEntryNotFound): + with service_exception_to_warning(): self.service.delete_figure(experiment_id=self.experiment_id, figure_name=figure_key) self._deleted_figures.remove(figure_key) @@ -559,7 +565,7 @@ def delete_analysis_result( service = service or self._service if service and self.auto_save: - with contextlib.suppress(ExperimentEntryNotFound): + with service_exception_to_warning(): self.service.delete_analysis_result(result_id=result_key) self._deleted_analysis_results.remove(result_key) @@ -621,9 +627,6 @@ def save(self, service: Optional["ExperimentServiceV1"] = None) -> None: Args: service: Experiment service to be used to save the data. If ``None``, the provider used to submit jobs will be used. - - Raises: - ExperimentError: If the experiment contains invalid data. """ service = service or self._service if not service: @@ -665,9 +668,6 @@ def save_all(self, service: Optional["ExperimentServiceV1"] = None) -> None: Args: service: Experiment service to be used to save the data. If ``None``, the provider used to submit jobs will be used. - - Raises: - ExperimentError: If the experiment contains invalid data. """ # TODO - track changes use_service = service or self._service @@ -680,10 +680,8 @@ def save_all(self, service: Optional["ExperimentServiceV1"] = None) -> None: result.save(service) for result in self._deleted_analysis_results.copy(): - try: + with service_exception_to_warning(): use_service.delete_analysis_result(result_id=result) - except ExperimentEntryNotFound: - pass self._deleted_analysis_results.remove(result) with self._figures.lock: @@ -703,10 +701,8 @@ def save_all(self, service: Optional["ExperimentServiceV1"] = None) -> None: ) for name in self._deleted_figures.copy(): - try: + with service_exception_to_warning(): use_service.delete_figure(experiment_id=self.experiment_id, figure_name=name) - except ExperimentEntryNotFound: - pass self._deleted_figures.remove(name) def serialize_metadata(self) -> str: @@ -989,13 +985,13 @@ def service(self) -> Optional["ExperimentServiceV1"]: @service.setter def service(self, service: "ExperimentServiceV1") -> None: - """Set the service to be used for storing experiment data remotely. + """Set the service to be used for storing experiment data. Args: service: Service to be used. Raises: - ExperimentError: If a remote experiment service is already being used. + ExperimentError: If an experiment service is already being used. """ if self._service: raise ExperimentError("An experiment service is already being used.") From 5e971467289dd2e5d5d7d61c3b43dfd74d4f9fc2 Mon Sep 17 00:00:00 2001 From: jessieyu Date: Wed, 16 Jun 2021 21:38:45 -0400 Subject: [PATCH 04/20] fix lint --- .../store_data/experiment_service.py | 18 +++++++++--------- qiskit_experiments/store_data/stored_data.py | 2 ++ test/stored_data/test_stored_data.py | 1 + 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/qiskit_experiments/store_data/experiment_service.py b/qiskit_experiments/store_data/experiment_service.py index 0cf172ffca..1c7267be41 100644 --- a/qiskit_experiments/store_data/experiment_service.py +++ b/qiskit_experiments/store_data/experiment_service.py @@ -17,7 +17,7 @@ from .device_component import DeviceComponent -T = TypeVar("T") +ExperimentClass = TypeVar("ExperimentClass") class ExperimentService: @@ -119,8 +119,8 @@ def update_experiment( @abstractmethod def experiment( - self, experiment_id: str, experiment_class: Optional[Type[T]] = None - ) -> Union[Dict, T]: + self, experiment_id: str, experiment_class: Optional[Type[ExperimentClass]] = None + ) -> Union[Dict, ExperimentClass]: """Retrieve a previously stored experiment. Args: @@ -142,14 +142,14 @@ def experiment( def experiments( self, limit: Optional[int] = 10, - experiment_class: Optional[Type[T]] = None, + experiment_class: Optional[Type[ExperimentClass]] = None, device_components: Optional[Union[str, DeviceComponent]] = None, experiment_type: Optional[str] = None, backend_name: Optional[str] = None, tags: Optional[List[str]] = None, tags_operator: Optional[str] = "OR", **filters: Any, - ) -> List[Union[Dict, T]]: + ) -> List[Union[Dict, ExperimentClass]]: """Retrieve all experiment data, with optional filtering. Args: @@ -251,8 +251,8 @@ def update_analysis_result( @abstractmethod def analysis_result( - self, result_id: str, result_class: Optional[Type[T]] = None - ) -> Union[Dict, T]: + self, result_id: str, result_class: Optional[Type[ExperimentClass]] = None + ) -> Union[Dict, ExperimentClass]: """Retrieve a previously stored experiment. Args: @@ -273,7 +273,7 @@ def analysis_result( def analysis_results( self, limit: Optional[int] = 10, - result_class: Optional[Type[T]] = None, + result_class: Optional[Type[ExperimentClass]] = None, device_components: Optional[Union[str, DeviceComponent]] = None, experiment_id: Optional[str] = None, result_type: Optional[str] = None, @@ -283,7 +283,7 @@ def analysis_results( tags: Optional[List[str]] = None, tags_operator: Optional[str] = "OR", **filters: Any, - ) -> List[Union[Dict, T]]: + ) -> List[Union[Dict, ExperimentClass]]: """Retrieve all analysis results, with optional filtering. Args: diff --git a/qiskit_experiments/store_data/stored_data.py b/qiskit_experiments/store_data/stored_data.py index 0a33c8fc51..e20b27b155 100644 --- a/qiskit_experiments/store_data/stored_data.py +++ b/qiskit_experiments/store_data/stored_data.py @@ -420,6 +420,7 @@ def add_figures( save = save_figure if save_figure is not None else self.auto_save if save and service: if HAS_MATPLOTLIB: + # pylint: disable=import-error from matplotlib import pyplot if isinstance(figure, pyplot.Figure): @@ -687,6 +688,7 @@ def save_all(self, service: Optional["ExperimentServiceV1"] = None) -> None: with self._figures.lock: for name, figure in self._figures.items(): if HAS_MATPLOTLIB: + # pylint: disable=import-error from matplotlib import pyplot if isinstance(figure, pyplot.Figure): diff --git a/test/stored_data/test_stored_data.py b/test/stored_data/test_stored_data.py index f7c18ebcd4..b99618525a 100644 --- a/test/stored_data/test_stored_data.py +++ b/test/stored_data/test_stored_data.py @@ -244,6 +244,7 @@ def test_add_figure(self): @skipIf(not HAS_MATPLOTLIB, "matplotlib not available.") def test_add_figure_plot(self): """Test adding a matplotlib figure.""" + # pylint: disable=import-error import matplotlib.pyplot as plt figure, ax = plt.subplots() From 6bd5c6455d9333c8d03cf6473286478ee23eecb2 Mon Sep 17 00:00:00 2001 From: jessieyu Date: Thu, 17 Jun 2021 14:41:44 -0400 Subject: [PATCH 05/20] log post processing failure --- qiskit_experiments/store_data/stored_data.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/qiskit_experiments/store_data/stored_data.py b/qiskit_experiments/store_data/stored_data.py index e20b27b155..b9d6a31ee2 100644 --- a/qiskit_experiments/store_data/stored_data.py +++ b/qiskit_experiments/store_data/stored_data.py @@ -204,7 +204,7 @@ def add_data( The following positional arguments are provided to the callback function: * This ``StoredData`` object. - * Index of the data added. + * Index of the last data added. * Additional keyword arguments passed to this method. **kwargs: Keyword arguments to be passed to the callback function. @@ -276,8 +276,13 @@ def _wait_for_job( except JobError as err: LOG.warning("Job %s failed: %s", job.job_id(), str(err)) return - if job_done_callback: - job_done_callback(self, data_index, **kwargs) + + try: + if job_done_callback: + job_done_callback(self, data_index, **kwargs) + except Exception: # pylint: disable=broad-except + LOG.warning("Post processing function failed:\n%s", traceback.format_exc()) + raise def _add_result_data(self, result: Result) -> None: """Add data from a Result object From 1780a37d3131920f2511c307e8c5f87c3869c1ce Mon Sep 17 00:00:00 2001 From: jessieyu Date: Thu, 17 Jun 2021 15:30:02 -0400 Subject: [PATCH 06/20] remove data index --- qiskit_experiments/store_data/stored_data.py | 16 +++++++--- test/stored_data/test_stored_data.py | 31 +++++++++++++++----- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/qiskit_experiments/store_data/stored_data.py b/qiskit_experiments/store_data/stored_data.py index b9d6a31ee2..c1728806d7 100644 --- a/qiskit_experiments/store_data/stored_data.py +++ b/qiskit_experiments/store_data/stored_data.py @@ -204,7 +204,6 @@ def add_data( The following positional arguments are provided to the callback function: * This ``StoredData`` object. - * Index of the last data added. * Additional keyword arguments passed to this method. **kwargs: Keyword arguments to be passed to the callback function. @@ -212,6 +211,13 @@ def add_data( Raises: TypeError: If the input data type is invalid. """ + with self._job_futures.lock: + if any(not fut.done() for _, fut in self._job_futures): + LOG.warning( + "Not all post-processing has finished. Adding new data " + "may create unexpected analysis results." + ) + if isinstance(data, (Job, BaseJob)): if self.backend and self.backend.name() != data.backend().name(): LOG.warning( @@ -251,7 +257,7 @@ def add_data( raise TypeError(f"Invalid data type {type(data)}.") if post_processing_callback is not None: - post_processing_callback(self, len(self._data) - 1, **kwargs) + post_processing_callback(self, **kwargs) def _wait_for_job( self, @@ -265,6 +271,9 @@ def _wait_for_job( job: Job to wait for. job_done_callback: Callback function to invoke when job finishes. **kwargs: Keyword arguments to be passed to the callback function. + + Raises: + Exception: If post processing failed. """ LOG.debug("Waiting for job %s to finish.", job.job_id()) try: @@ -272,14 +281,13 @@ def _wait_for_job( with self._data.lock: # Hold the lock so we add the block of results together. self._add_result_data(job_result) - data_index = len(self._data) - 1 except JobError as err: LOG.warning("Job %s failed: %s", job.job_id(), str(err)) return try: if job_done_callback: - job_done_callback(self, data_index, **kwargs) + job_done_callback(self, **kwargs) except Exception: # pylint: disable=broad-except LOG.warning("Post processing function failed:\n%s", traceback.format_exc()) raise diff --git a/test/stored_data/test_stored_data.py b/test/stored_data/test_stored_data.py index b99618525a..ef53153976 100644 --- a/test/stored_data/test_stored_data.py +++ b/test/stored_data/test_stored_data.py @@ -41,7 +41,10 @@ ) -class TestExperimentData(QiskitTestCase): +import unittest + + +class TestExperimentData(unittest.TestCase): """Test the ExperimentData class.""" def setUp(self): @@ -132,12 +135,11 @@ def test_add_data_job(self): def test_add_data_job_callback(self): """Test add job data with callback.""" - def _callback(_exp_data, data_index): + def _callback(_exp_data): self.assertIsInstance(_exp_data, StoredData) self.assertEqual( [dat["counts"] for dat in _exp_data.data()], a_job.result().get_counts() ) - self.assertEqual(len(a_job.result().results) - 1, data_index) exp_data.add_figures(str.encode("hello world")) exp_data.add_analysis_results(mock.MagicMock()) nonlocal called_back @@ -155,12 +157,11 @@ def _callback(_exp_data, data_index): def test_add_data_callback(self): """Test add data with callback.""" - def _callback(_exp_data, data_index): + def _callback(_exp_data): self.assertIsInstance(_exp_data, StoredData) nonlocal called_back_count, expected_data, subtests expected_data.extend(subtests[called_back_count][1]) self.assertEqual([dat["counts"] for dat in _exp_data.data()], expected_data) - self.assertEqual(len(_exp_data.data()) - 1, data_index) called_back_count += 1 a_result = self._get_job_result(1) @@ -188,9 +189,8 @@ def _callback(_exp_data, data_index): def test_add_data_job_callback_kwargs(self): """Test add job data with callback and additional arguments.""" - def _callback(_exp_data, data_index, **kwargs): + def _callback(_exp_data, **kwargs): self.assertIsInstance(_exp_data, StoredData) - self.assertEqual(len(_exp_data.data()) - 1, data_index) self.assertEqual({"foo": callback_kwargs}, kwargs) nonlocal called_back called_back = True @@ -205,6 +205,23 @@ def _callback(_exp_data, data_index, **kwargs): exp_data.block_for_results() self.assertTrue(called_back) + def test_add_data_pending_post_processing(self): + """Test add job data while post processing is still running.""" + + def _callback(_exp_data, **kwargs): + kwargs["event"].wait(timeout=3) + + a_job = mock.create_autospec(Job, instance=True) + a_job.result.return_value = self._get_job_result(2) + + event = threading.Event() + self.addCleanup(event.set) + + exp_data = StoredData(experiment_type="qiskit_test") + exp_data.add_data(a_job, _callback, event=event) + with self.assertLogs("qiskit_experiments", "WARNING"): + exp_data.add_data({"foo": "bar"}) + def test_get_data(self): """Test getting data.""" data1 = [] From e3a2d318326039f1db16a41cf8785d7e5477a606 Mon Sep 17 00:00:00 2001 From: jessieyu Date: Thu, 17 Jun 2021 16:27:46 -0400 Subject: [PATCH 07/20] fix test can package typo --- .../{store_data => stored_data}/__init__.py | 0 .../analysis_result.py | 0 .../device_component.py | 0 .../{store_data => stored_data}/exceptions.py | 0 .../experiment_service.py | 0 .../{store_data => stored_data}/json.py | 0 .../{store_data => stored_data}/stored_data.py | 0 .../{store_data => stored_data}/utils.py | 0 test/stored_data/test_analysisresult.py | 8 ++++---- test/stored_data/test_stored_data.py | 16 ++++++---------- 10 files changed, 10 insertions(+), 14 deletions(-) rename qiskit_experiments/{store_data => stored_data}/__init__.py (100%) rename qiskit_experiments/{store_data => stored_data}/analysis_result.py (100%) rename qiskit_experiments/{store_data => stored_data}/device_component.py (100%) rename qiskit_experiments/{store_data => stored_data}/exceptions.py (100%) rename qiskit_experiments/{store_data => stored_data}/experiment_service.py (100%) rename qiskit_experiments/{store_data => stored_data}/json.py (100%) rename qiskit_experiments/{store_data => stored_data}/stored_data.py (100%) rename qiskit_experiments/{store_data => stored_data}/utils.py (100%) diff --git a/qiskit_experiments/store_data/__init__.py b/qiskit_experiments/stored_data/__init__.py similarity index 100% rename from qiskit_experiments/store_data/__init__.py rename to qiskit_experiments/stored_data/__init__.py diff --git a/qiskit_experiments/store_data/analysis_result.py b/qiskit_experiments/stored_data/analysis_result.py similarity index 100% rename from qiskit_experiments/store_data/analysis_result.py rename to qiskit_experiments/stored_data/analysis_result.py diff --git a/qiskit_experiments/store_data/device_component.py b/qiskit_experiments/stored_data/device_component.py similarity index 100% rename from qiskit_experiments/store_data/device_component.py rename to qiskit_experiments/stored_data/device_component.py diff --git a/qiskit_experiments/store_data/exceptions.py b/qiskit_experiments/stored_data/exceptions.py similarity index 100% rename from qiskit_experiments/store_data/exceptions.py rename to qiskit_experiments/stored_data/exceptions.py diff --git a/qiskit_experiments/store_data/experiment_service.py b/qiskit_experiments/stored_data/experiment_service.py similarity index 100% rename from qiskit_experiments/store_data/experiment_service.py rename to qiskit_experiments/stored_data/experiment_service.py diff --git a/qiskit_experiments/store_data/json.py b/qiskit_experiments/stored_data/json.py similarity index 100% rename from qiskit_experiments/store_data/json.py rename to qiskit_experiments/stored_data/json.py diff --git a/qiskit_experiments/store_data/stored_data.py b/qiskit_experiments/stored_data/stored_data.py similarity index 100% rename from qiskit_experiments/store_data/stored_data.py rename to qiskit_experiments/stored_data/stored_data.py diff --git a/qiskit_experiments/store_data/utils.py b/qiskit_experiments/stored_data/utils.py similarity index 100% rename from qiskit_experiments/store_data/utils.py rename to qiskit_experiments/stored_data/utils.py diff --git a/test/stored_data/test_analysisresult.py b/test/stored_data/test_analysisresult.py index 6a41506997..30cc064d20 100644 --- a/test/stored_data/test_analysisresult.py +++ b/test/stored_data/test_analysisresult.py @@ -20,10 +20,10 @@ import numpy as np from qiskit.test import QiskitTestCase -from qiskit_experiments.store_data import AnalysisResultV1 as AnalysisResult -from qiskit_experiments.store_data.device_component import Qubit, Resonator, to_component -from qiskit_experiments.store_data.experiment_service import ExperimentServiceV1 -from qiskit_experiments.store_data.exceptions import ExperimentError +from qiskit_experiments.stored_data import AnalysisResultV1 as AnalysisResult +from qiskit_experiments.stored_data.device_component import Qubit, Resonator, to_component +from qiskit_experiments.stored_data.experiment_service import ExperimentServiceV1 +from qiskit_experiments.stored_data.exceptions import ExperimentError class TestAnalysisResult(QiskitTestCase): diff --git a/test/stored_data/test_stored_data.py b/test/stored_data/test_stored_data.py index ef53153976..b76978be8a 100644 --- a/test/stored_data/test_stored_data.py +++ b/test/stored_data/test_stored_data.py @@ -32,19 +32,16 @@ from qiskit.providers import JobV1 as Job from qiskit.providers import JobStatus from qiskit.tools.visualization import HAS_MATPLOTLIB -from qiskit_experiments.store_data import StoredDataV1 as StoredData -from qiskit_experiments.store_data import ExperimentServiceV1 -from qiskit_experiments.store_data.exceptions import ( +from qiskit_experiments.stored_data import StoredDataV1 as StoredData +from qiskit_experiments.stored_data import ExperimentServiceV1 +from qiskit_experiments.stored_data.exceptions import ( ExperimentError, ExperimentEntryNotFound, ExperimentEntryExists, ) -import unittest - - -class TestExperimentData(unittest.TestCase): +class TestStoredData(QiskitTestCase): """Test the ExperimentData class.""" def setUp(self): @@ -666,9 +663,8 @@ def _post_processing(*args, **kwargs): # pylint: disable=unused-argument def test_source(self): """Test getting experiment source.""" exp_data = StoredData(experiment_type="qiskit_test") - source_vals = "\n".join([str(val) for val in exp_data.source.values()]) - self.assertIn("StoredDataV1", source_vals) - self.assertIn("qiskit-terra", source_vals) + self.assertIn("StoredDataV1", exp_data.source["class"]) + self.assertTrue(exp_data.source["qiskit_version"]) def test_block_for_jobs(self): """Test blocking for jobs.""" From 1fd9dd55373fadb8220e10d63df2abd8fce8d699 Mon Sep 17 00:00:00 2001 From: jessieyu Date: Wed, 23 Jun 2021 11:19:45 -0400 Subject: [PATCH 08/20] remove experiment_class and result_class --- .../stored_data/experiment_service.py | 38 +++++++------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/qiskit_experiments/stored_data/experiment_service.py b/qiskit_experiments/stored_data/experiment_service.py index 1c7267be41..ab2c0935f8 100644 --- a/qiskit_experiments/stored_data/experiment_service.py +++ b/qiskit_experiments/stored_data/experiment_service.py @@ -13,12 +13,11 @@ """Experiment service abstract interface.""" from abc import ABC, abstractmethod -from typing import Optional, Dict, List, Any, Union, Tuple, Type, TypeVar +from typing import Optional, Dict, List, Any, Union, Tuple +from types import SimpleNamespace from .device_component import DeviceComponent -ExperimentClass = TypeVar("ExperimentClass") - class ExperimentService: """Base common type for all versioned ExperimentService abstract classes. @@ -33,9 +32,12 @@ class ExperimentService: class ExperimentServiceV1(ExperimentService, ABC): - """Class to provide experiment service. + """Interface for providing experiment service. + + This class defines the interface ``qiskit_experiments`` expects from an + experiment service. - The experiment service allows you to store experiment data and metadata + An experiment service allows you to store experiment data and metadata in a database. An experiment can have one or more jobs, analysis results, and figures. @@ -119,15 +121,12 @@ def update_experiment( @abstractmethod def experiment( - self, experiment_id: str, experiment_class: Optional[Type[ExperimentClass]] = None - ) -> Union[Dict, ExperimentClass]: + self, experiment_id: str + ) -> SimpleNamespace: """Retrieve a previously stored experiment. Args: experiment_id: Experiment ID. - experiment_class: Class used to instantiate the returned data object. - If a class is provided, its ``from_data()`` method is called - with the retrieved data, and its return value is returned. Returns: A dictionary containing the retrieved experiment data if `experiment_class` @@ -142,21 +141,17 @@ def experiment( def experiments( self, limit: Optional[int] = 10, - experiment_class: Optional[Type[ExperimentClass]] = None, device_components: Optional[Union[str, DeviceComponent]] = None, experiment_type: Optional[str] = None, backend_name: Optional[str] = None, tags: Optional[List[str]] = None, tags_operator: Optional[str] = "OR", **filters: Any, - ) -> List[Union[Dict, ExperimentClass]]: + ) -> List[SimpleNamespace]: """Retrieve all experiment data, with optional filtering. Args: limit: Number of experiments to retrieve. ``None`` means no limit. - experiment_class: Class used to instantiate the returned data object. - If a class is provided, its ``from_data()`` method is called - with the retrieved data, and its return value is returned. device_components: Filter by device components. An experiment must have analysis results with device components matching the given list exactly to be included. experiment_type: Experiment type used for filtering. @@ -251,15 +246,12 @@ def update_analysis_result( @abstractmethod def analysis_result( - self, result_id: str, result_class: Optional[Type[ExperimentClass]] = None - ) -> Union[Dict, ExperimentClass]: + self, result_id: str + ) -> SimpleNamespace: """Retrieve a previously stored experiment. Args: result_id: Analysis result ID. - result_class: Class used to instantiate the returned data object. - If a class is provided, its ``from_data()`` method is called - with the retrieved data, and its return value is returned. Returns: Retrieved analysis result. @@ -273,7 +265,6 @@ def analysis_result( def analysis_results( self, limit: Optional[int] = 10, - result_class: Optional[Type[ExperimentClass]] = None, device_components: Optional[Union[str, DeviceComponent]] = None, experiment_id: Optional[str] = None, result_type: Optional[str] = None, @@ -283,14 +274,11 @@ def analysis_results( tags: Optional[List[str]] = None, tags_operator: Optional[str] = "OR", **filters: Any, - ) -> List[Union[Dict, ExperimentClass]]: + ) -> List[SimpleNamespace]: """Retrieve all analysis results, with optional filtering. Args: limit: Number of analysis results to retrieve. ``None`` means no limit. - result_class: Class used to instantiate the returned data object. - If a class is provided, its ``from_data()`` method is called - with the retrieved data, and its return value is returned. device_components: Target device components, such as qubits. experiment_id: Experiment ID used for filtering. result_type: Analysis result type used for filtering. From 404d7be03c774096438b7278a332b8c82aa6e32c Mon Sep 17 00:00:00 2001 From: jessieyu Date: Wed, 23 Jun 2021 16:52:17 -0400 Subject: [PATCH 09/20] fix lint --- qiskit_experiments/stored_data/experiment_service.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/qiskit_experiments/stored_data/experiment_service.py b/qiskit_experiments/stored_data/experiment_service.py index ab2c0935f8..42357c2f79 100644 --- a/qiskit_experiments/stored_data/experiment_service.py +++ b/qiskit_experiments/stored_data/experiment_service.py @@ -120,9 +120,7 @@ def update_experiment( pass @abstractmethod - def experiment( - self, experiment_id: str - ) -> SimpleNamespace: + def experiment(self, experiment_id: str) -> SimpleNamespace: """Retrieve a previously stored experiment. Args: @@ -245,9 +243,7 @@ def update_analysis_result( pass @abstractmethod - def analysis_result( - self, result_id: str - ) -> SimpleNamespace: + def analysis_result(self, result_id: str) -> SimpleNamespace: """Retrieve a previously stored experiment. Args: From f09667cf23de9c19699fb86201fc2130aa752c65 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Mon, 28 Jun 2021 16:37:44 -0400 Subject: [PATCH 10/20] doc update Co-authored-by: Naoki Kanazawa --- qiskit_experiments/stored_data/analysis_result.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit_experiments/stored_data/analysis_result.py b/qiskit_experiments/stored_data/analysis_result.py index c27aafe5c6..ca0ad12427 100644 --- a/qiskit_experiments/stored_data/analysis_result.py +++ b/qiskit_experiments/stored_data/analysis_result.py @@ -191,7 +191,7 @@ def save(self, service: Optional["ExperimentServiceV1"] = None) -> None: service = service or self._service if not service: LOG.warning( - "Analysis result cannot be saved because no " "experiment service is available." + "Analysis result cannot be saved because no experiment service is available." ) return From c98e2c678793eca641c2a08ca74d354a5dc3ef1d Mon Sep 17 00:00:00 2001 From: jessieyu Date: Mon, 28 Jun 2021 17:02:56 -0400 Subject: [PATCH 11/20] rename classes --- .../database_service/__init__.py | 53 +++++++ .../database_service.py} | 44 +++--- .../db_analysis_result.py} | 23 +-- .../db_experiment_data.py} | 70 +++++---- .../device_component.py | 0 .../exceptions.py | 10 +- .../{stored_data => database_service}/json.py | 0 .../utils.py | 11 +- qiskit_experiments/stored_data/__init__.py | 53 ------- .../__init__.py | 2 +- .../test_db_analysis_result.py} | 35 +++-- .../test_db_experiment_data.py} | 136 +++++++++--------- 12 files changed, 217 insertions(+), 220 deletions(-) create mode 100644 qiskit_experiments/database_service/__init__.py rename qiskit_experiments/{stored_data/experiment_service.py => database_service/database_service.py} (90%) rename qiskit_experiments/{stored_data/analysis_result.py => database_service/db_analysis_result.py} (94%) rename qiskit_experiments/{stored_data/stored_data.py => database_service/db_experiment_data.py} (93%) rename qiskit_experiments/{stored_data => database_service}/device_component.py (100%) rename qiskit_experiments/{stored_data => database_service}/exceptions.py (71%) rename qiskit_experiments/{stored_data => database_service}/json.py (100%) rename qiskit_experiments/{stored_data => database_service}/utils.py (93%) delete mode 100644 qiskit_experiments/stored_data/__init__.py rename test/{stored_data => database_service}/__init__.py (91%) rename test/{stored_data/test_analysisresult.py => database_service/test_db_analysis_result.py} (82%) rename test/{stored_data/test_stored_data.py => database_service/test_db_experiment_data.py} (83%) diff --git a/qiskit_experiments/database_service/__init__.py b/qiskit_experiments/database_service/__init__.py new file mode 100644 index 0000000000..eb5d298962 --- /dev/null +++ b/qiskit_experiments/database_service/__init__.py @@ -0,0 +1,53 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" +============================================================= +Database Service (:mod:`qiskit_experiments.database_service`) +============================================================= + +.. currentmodule:: qiskit_experiments.database_service + +This subpackage contains classes used to define the data structure of +an experiment, including its data, metadata, analysis results, and figures, as +well as the interface to an experiment database service. An experiment database +service allows one to store, retrieve, and query experiment related data. + +Classes +======= + +.. autosummary:: + :toctree: ../stubs/ + + DbExperimentData + DbExperimentDataV1 + DbAnalysisResult + DbAnalysisResultV1 + DatabaseService + DatabaseServiceV1 + + +Exceptions +========== + +.. autosummary:: + :toctree: ../stubs/ + + DbExperimentDataError + DbExperimentEntryExists + DbExperimentEntryNotFound +""" + +from .db_experiment_data import DbExperimentData, DbExperimentDataV1 +from .db_analysis_result import DbAnalysisResult, DbAnalysisResultV1 +from .database_service import DatabaseService, DatabaseServiceV1 +from .exceptions import DbExperimentDataError, DbExperimentEntryExists, DbExperimentEntryNotFound diff --git a/qiskit_experiments/stored_data/experiment_service.py b/qiskit_experiments/database_service/database_service.py similarity index 90% rename from qiskit_experiments/stored_data/experiment_service.py rename to qiskit_experiments/database_service/database_service.py index 42357c2f79..f9680d725b 100644 --- a/qiskit_experiments/stored_data/experiment_service.py +++ b/qiskit_experiments/database_service/database_service.py @@ -10,17 +10,18 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Experiment service abstract interface.""" +"""Experiment database service abstract interface.""" from abc import ABC, abstractmethod from typing import Optional, Dict, List, Any, Union, Tuple -from types import SimpleNamespace + +from qiskit.providers import Options from .device_component import DeviceComponent -class ExperimentService: - """Base common type for all versioned ExperimentService abstract classes. +class DatabaseService: + """Base common type for all versioned DatabaseService abstract classes. Note this class should not be inherited from directly, it is intended to be used for type checking. When implementing a subclass you should use @@ -31,13 +32,13 @@ class ExperimentService: version = 0 -class ExperimentServiceV1(ExperimentService, ABC): - """Interface for providing experiment service. +class DatabaseServiceV1(DatabaseService, ABC): + """Interface for providing experiment database service. This class defines the interface ``qiskit_experiments`` expects from an - experiment service. + experiment database service. - An experiment service allows you to store experiment data and metadata + An experiment database service allows you to store experiment data and metadata in a database. An experiment can have one or more jobs, analysis results, and figures. @@ -48,16 +49,16 @@ class ExperimentServiceV1(ExperimentService, ABC): version = 1 def __init__(self): - """Initialize an ExperimentService instance.""" + """Initialize an DatabaseService instance.""" self._options = self._default_options() @classmethod @abstractmethod - def _default_options(cls) -> Dict: + def _default_options(cls) -> Options: """Return the default options Returns: - A dictionary of default options. + Default options. """ pass @@ -120,15 +121,14 @@ def update_experiment( pass @abstractmethod - def experiment(self, experiment_id: str) -> SimpleNamespace: + def experiment(self, experiment_id: str) -> Dict: """Retrieve a previously stored experiment. Args: experiment_id: Experiment ID. Returns: - A dictionary containing the retrieved experiment data if `experiment_class` - is ``None``. Otherwise an instance of the `experiment_class` class. + A dictionary containing the retrieved experiment data. Raises: ExperimentEntryNotFound: If the experiment does not exist. @@ -145,11 +145,11 @@ def experiments( tags: Optional[List[str]] = None, tags_operator: Optional[str] = "OR", **filters: Any, - ) -> List[SimpleNamespace]: + ) -> List[Dict]: """Retrieve all experiment data, with optional filtering. Args: - limit: Number of experiments to retrieve. ``None`` means no limit. + limit: Number of experiment data entries to retrieve. ``None`` means no limit. device_components: Filter by device components. An experiment must have analysis results with device components matching the given list exactly to be included. experiment_type: Experiment type used for filtering. @@ -167,9 +167,7 @@ def experiments( **filters: Additional filtering keywords supported by the service provider. Returns: - A list of experiments. Each experiment is either a dictionary containing the - retrieved experiment data, if `experiment_class` - is ``None``, or an instance of the `experiment_class` class. + A list of experiments. """ pass @@ -243,7 +241,7 @@ def update_analysis_result( pass @abstractmethod - def analysis_result(self, result_id: str) -> SimpleNamespace: + def analysis_result(self, result_id: str) -> Dict: """Retrieve a previously stored experiment. Args: @@ -270,7 +268,7 @@ def analysis_results( tags: Optional[List[str]] = None, tags_operator: Optional[str] = "OR", **filters: Any, - ) -> List[SimpleNamespace]: + ) -> List[Dict]: """Retrieve all analysis results, with optional filtering. Args: @@ -295,9 +293,7 @@ def analysis_results( **filters: Additional filtering keywords supported by the service provider. Returns: - A list of analysis results. Each analysis result is either a dictionary - containing the retrieved analysis result, if `result_class` - is ``None``, or an instance of the `result_class` class. + A list of analysis results. """ pass diff --git a/qiskit_experiments/stored_data/analysis_result.py b/qiskit_experiments/database_service/db_analysis_result.py similarity index 94% rename from qiskit_experiments/stored_data/analysis_result.py rename to qiskit_experiments/database_service/db_analysis_result.py index c27aafe5c6..40fbefa088 100644 --- a/qiskit_experiments/stored_data/analysis_result.py +++ b/qiskit_experiments/database_service/db_analysis_result.py @@ -21,7 +21,7 @@ from .json import NumpyEncoder, NumpyDecoder from .utils import save_data, qiskit_version -from .exceptions import ExperimentError +from .exceptions import DbExperimentDataError from .device_component import DeviceComponent, to_component LOG = logging.getLogger(__name__) @@ -40,7 +40,7 @@ def _wrapped(self, *args, **kwargs): return _wrapped -class AnalysisResult: +class DbAnalysisResult: """Base common type for all versioned AnalysisResult abstract classes. Note this class should not be inherited from directly, it is intended @@ -52,8 +52,11 @@ class AnalysisResult: version = 0 -class AnalysisResultV1(AnalysisResult): - """Class representing an analysis result for an experiment.""" +class DbAnalysisResultV1(DbAnalysisResult): + """Class representing an analysis result for an experiment. + + Analysis results can also be stored in a database. + """ version = 1 _data_version = 1 @@ -73,7 +76,7 @@ def __init__( quality: Optional[str] = None, verified: bool = False, tags: Optional[List[str]] = None, - service: Optional["ExperimentServiceV1"] = None, + service: Optional["DatabaseServiceV1"] = None, **kwargs, ): """AnalysisResult constructor. @@ -155,7 +158,7 @@ def from_data( device_components: List[Union[DeviceComponent, str]], experiment_id: str, **kwargs, - ) -> "AnalysisResultV1": + ) -> "DbAnalysisResultV1": """Reconstruct the analysis result from input data. Args: @@ -178,7 +181,7 @@ def from_data( **kwargs, ) - def save(self, service: Optional["ExperimentServiceV1"] = None) -> None: + def save(self, service: Optional["DatabaseServiceV1"] = None) -> None: """Save this analysis result in the database. Args: @@ -325,7 +328,7 @@ def experiment_id(self) -> str: return self._experiment_id @property - def service(self) -> Optional["ExperimentServiceV1"]: + def service(self) -> Optional["DatabaseServiceV1"]: """Return the database service. Returns: @@ -335,7 +338,7 @@ def service(self) -> Optional["ExperimentServiceV1"]: return self._service @service.setter - def service(self, service: "ExperimentServiceV1") -> None: + def service(self, service: "DatabaseServiceV1") -> None: """Set the service to be used for storing result data in a database. Args: @@ -345,7 +348,7 @@ def service(self, service: "ExperimentServiceV1") -> None: ExperimentError: If an experiment service is already being used. """ if self._service: - raise ExperimentError("An experiment service is already being used.") + raise DbExperimentDataError("An experiment service is already being used.") self._service = service @property diff --git a/qiskit_experiments/stored_data/stored_data.py b/qiskit_experiments/database_service/db_experiment_data.py similarity index 93% rename from qiskit_experiments/stored_data/stored_data.py rename to qiskit_experiments/database_service/db_experiment_data.py index c1728806d7..845be84893 100644 --- a/qiskit_experiments/stored_data/stored_data.py +++ b/qiskit_experiments/database_service/db_experiment_data.py @@ -30,8 +30,8 @@ from qiskit.providers.exceptions import JobError from qiskit.visualization import HAS_MATPLOTLIB -from .exceptions import ExperimentError, ExperimentEntryNotFound, ExperimentEntryExists -from .analysis_result import AnalysisResultV1 as AnalysisResult +from .exceptions import DbExperimentDataError, DbExperimentEntryNotFound, DbExperimentEntryExists +from .db_analysis_result import DbAnalysisResultV1 as DbAnalysisResult from .json import NumpyEncoder, NumpyDecoder from .utils import ( save_data, @@ -66,8 +66,8 @@ def service_exception_to_warning(): LOG.warning("Experiment service operation failed: %s", traceback.format_exc()) -class StoredData: - """Base common type for all versioned StoredData classes. +class DbExperimentData: + """Base common type for all versioned DbExperimentData classes. Note this class should not be inherited from directly, it is intended to be used for type checking. When implementing a provider you should use @@ -78,13 +78,13 @@ class StoredData: version = 0 -class StoredDataV1(StoredData): - """Class to handle stored data. +class DbExperimentDataV1(DbExperimentData): + """Class to define and handle experiment data stored in a database. - This class serves as a container for data to be stored in a database, which - may include experiment metadata, analysis results, and figures. It also - provides methods used to interact with the database, such as storing into - and retrieving from the database. + This class serves as a container for experiment related data to be stored + in a database, which may include experiment metadata, analysis results, + and figures. It also provides methods used to interact with the database, + such as storing into and retrieving from the database. """ version = 1 @@ -108,7 +108,7 @@ def __init__( notes: Optional[str] = None, **kwargs, ): - """Initializes the StoredDataV1 instance. + """Initializes the DbExperimentData instance. Args: experiment_type: Experiment type. @@ -117,7 +117,7 @@ def __init__( tags: Tags to be associated with the experiment. job_ids: IDs of jobs submitted for the experiment. share_level: Whether this experiment can be shared with others. This - is applicable only if the experiment service supports sharing. See + is applicable only if the database service supports sharing. See the specific service provider's documentation on valid values. metadata: Additional experiment metadata. figure_names: Name of figures associated with this experiment. @@ -168,7 +168,6 @@ def _set_service_from_backend(self, backend: Union[Backend, BaseBackend]) -> Non """ with contextlib.suppress(Exception): self._service = backend.provider().service("experiment") - with contextlib.suppress(Exception): self.auto_save = self._service.option("auto_save") def add_data( @@ -203,7 +202,7 @@ def add_data( the job finishes successfully. The following positional arguments are provided to the callback function: - * This ``StoredData`` object. + * This ``DbExperimentData`` object. * Additional keyword arguments passed to this method. **kwargs: Keyword arguments to be passed to the callback function. @@ -250,7 +249,6 @@ def add_data( elif isinstance(data, Result): self._add_result_data(data) elif isinstance(data, list): - # TODO use loop instead of recursion for fewer save() for dat in data: self.add_data(dat) else: @@ -364,7 +362,7 @@ def add_figures( figure_names: Optional[Union[List[str], str]] = None, overwrite: bool = False, save_figure: Optional[bool] = None, - service: Optional["ExperimentServiceV1"] = None, + service: Optional["DatabaseServiceV1"] = None, ) -> Union[str, List[str]]: """Add the experiment figure. @@ -417,7 +415,7 @@ def add_figures( existing_figure = fig_name in self._figures if existing_figure and not overwrite: - raise ExperimentEntryExists( + raise DbExperimentEntryExists( f"A figure with the name {fig_name} for this experiment " f"already exists. Specify overwrite=True if you " f"want to overwrite it." @@ -458,7 +456,7 @@ def add_figures( def delete_figure( self, figure_key: Union[str, int], - service: Optional["ExperimentServiceV1"] = None, + service: Optional["DatabaseServiceV1"] = None, ) -> str: """Add the experiment figure. @@ -476,7 +474,7 @@ def delete_figure( if isinstance(figure_key, int): figure_key = self._figures.keys()[figure_key] elif figure_key not in self._figures: - raise ExperimentEntryNotFound(f"Figure {figure_key} not found.") + raise DbExperimentEntryNotFound(f"Figure {figure_key} not found.") del self._figures[figure_key] self._deleted_figures.append(figure_key) @@ -517,7 +515,7 @@ def figure( self._figures[figure_key] = figure_data if figure_data is None: - raise ExperimentEntryNotFound(f"Figure {figure_key} not found.") + raise DbExperimentEntryNotFound(f"Figure {figure_key} not found.") if file_name: with open(file_name, "wb") as output: @@ -528,8 +526,8 @@ def figure( @auto_save def add_analysis_results( self, - results: Union[AnalysisResult, List[AnalysisResult]], - service: "ExperimentServiceV1" = None, + results: Union[DbAnalysisResult, List[DbAnalysisResult]], + service: "DatabaseServiceV1" = None, ) -> None: """Save the analysis result. @@ -544,7 +542,7 @@ def add_analysis_results( for result in results: self._analysis_results[result.result_id] = result - with contextlib.suppress(ExperimentError): + with contextlib.suppress(DbExperimentDataError): result.service = self.service result.auto_save = self.auto_save @@ -554,7 +552,7 @@ def add_analysis_results( @auto_save def delete_analysis_result( - self, result_key: Union[int, str], service: "ExperimentServiceV1" = None + self, result_key: Union[int, str], service: "DatabaseServiceV1" = None ) -> str: """Delete the analysis result. @@ -572,7 +570,7 @@ def delete_analysis_result( if isinstance(result_key, int): result_key = self._analysis_results.keys()[result_key] elif result_key not in self._analysis_results: - raise ExperimentEntryNotFound(f"Analysis result {result_key} not found.") + raise DbExperimentEntryNotFound(f"Analysis result {result_key} not found.") del self._analysis_results[result_key] self._deleted_analysis_results.append(result_key) @@ -587,7 +585,7 @@ def delete_analysis_result( def analysis_result( self, index: Optional[Union[int, slice, str]] = None, refresh: bool = False - ) -> Union[AnalysisResult, List[AnalysisResult]]: + ) -> Union[DbAnalysisResult, List[DbAnalysisResult]]: """Return analysis results associated with this experiment. Args: @@ -621,17 +619,17 @@ def analysis_result( return self._analysis_results.values()[index] if isinstance(index, str): if index not in self._analysis_results: - raise ExperimentEntryNotFound(f"Analysis result {index} not found.") + raise DbExperimentEntryNotFound(f"Analysis result {index} not found.") return self._analysis_results[index] raise TypeError(f"Invalid index type {type(index)}.") - def save(self, service: Optional["ExperimentServiceV1"] = None) -> None: + def save(self, service: Optional["DatabaseServiceV1"] = None) -> None: """Save this experiment in the database. Note: Not all experiment properties are saved. - See :meth:`qiskit.providers.experiment.ExperimentServiceV1.create_experiment` + See :meth:`qiskit.providers.experiment.DatabaseServiceV1.create_experiment` for fields that are saved. Note: @@ -673,7 +671,7 @@ def save(self, service: Optional["ExperimentServiceV1"] = None) -> None: update_data=update_data, ) - def save_all(self, service: Optional["ExperimentServiceV1"] = None) -> None: + def save_all(self, service: Optional["DatabaseServiceV1"] = None) -> None: """Save this experiment and its analysis results and figures in the database. Note: @@ -747,8 +745,8 @@ def from_data( experiment_id: str, metadata: Optional[Dict] = None, **kwargs, - ) -> "StoredDataV1": - """Reconstruct a StoredData using the input data. + ) -> "DbExperimentDataV1": + """Reconstruct a DbExperimentDataV1 using the input data. Args: experiment_type: Experiment type. @@ -757,7 +755,7 @@ def from_data( **kwargs: Additional experiment attributes. Returns: - An StoredData instance. + An DbExperimentDataV1 instance. """ if metadata: metadata = cls.deserialize_metadata(json.dumps(metadata)) @@ -990,7 +988,7 @@ def notes(self, new_notes: str) -> None: self.save() @property - def service(self) -> Optional["ExperimentServiceV1"]: + def service(self) -> Optional["DatabaseServiceV1"]: """Return the database service. Returns: @@ -999,7 +997,7 @@ def service(self) -> Optional["ExperimentServiceV1"]: return self._service @service.setter - def service(self, service: "ExperimentServiceV1") -> None: + def service(self, service: "DatabaseServiceV1") -> None: """Set the service to be used for storing experiment data. Args: @@ -1009,7 +1007,7 @@ def service(self, service: "ExperimentServiceV1") -> None: ExperimentError: If an experiment service is already being used. """ if self._service: - raise ExperimentError("An experiment service is already being used.") + raise DbExperimentDataError("An experiment service is already being used.") self._service = service with contextlib.suppress(Exception): self.auto_save = self._service.option("auto_save") diff --git a/qiskit_experiments/stored_data/device_component.py b/qiskit_experiments/database_service/device_component.py similarity index 100% rename from qiskit_experiments/stored_data/device_component.py rename to qiskit_experiments/database_service/device_component.py diff --git a/qiskit_experiments/stored_data/exceptions.py b/qiskit_experiments/database_service/exceptions.py similarity index 71% rename from qiskit_experiments/stored_data/exceptions.py rename to qiskit_experiments/database_service/exceptions.py index 1abfb80456..3e9af85fac 100644 --- a/qiskit_experiments/stored_data/exceptions.py +++ b/qiskit_experiments/database_service/exceptions.py @@ -10,24 +10,24 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Exceptions for errors raised while handling experiments.""" +"""Exceptions for errors raised by experiment service data.""" from qiskit.exceptions import QiskitError -class ExperimentError(QiskitError): - """Base class for errors raised while handling experiments.""" +class DbExperimentDataError(QiskitError): + """Base class for errors raised by experiment service data.""" pass -class ExperimentEntryNotFound(ExperimentError): +class DbExperimentEntryNotFound(DbExperimentDataError): """Errors raised when an experiment entry cannot be found.""" pass -class ExperimentEntryExists(ExperimentError): +class DbExperimentEntryExists(DbExperimentDataError): """Errors raised when an experiment entry already exists.""" pass diff --git a/qiskit_experiments/stored_data/json.py b/qiskit_experiments/database_service/json.py similarity index 100% rename from qiskit_experiments/stored_data/json.py rename to qiskit_experiments/database_service/json.py diff --git a/qiskit_experiments/stored_data/utils.py b/qiskit_experiments/database_service/utils.py similarity index 93% rename from qiskit_experiments/stored_data/utils.py rename to qiskit_experiments/database_service/utils.py index 201a4edd8e..919c0fd753 100644 --- a/qiskit_experiments/stored_data/utils.py +++ b/qiskit_experiments/database_service/utils.py @@ -26,8 +26,9 @@ from dateutil import tz from qiskit.version import __version__ as terra_version +from ..version import __version__ as experiments_version -from .exceptions import ExperimentEntryNotFound, ExperimentEntryExists, ExperimentError +from .exceptions import DbExperimentEntryNotFound, DbExperimentEntryExists, DbExperimentDataError LOG = logging.getLogger(__name__) @@ -37,7 +38,7 @@ def qiskit_version(): try: return pkg_resources.get_distribution("qiskit").version except Exception: # pylint: disable=broad-except - return {"qiskit-terra": terra_version} + return {"qiskit-terra": terra_version, "qiskit-experiments": experiments_version} def parse_timestamp(utc_dt: Union[datetime, str]) -> datetime: @@ -121,14 +122,14 @@ def save_data( if is_new: try: return True, new_func(**{**new_data, **update_data}) - except ExperimentEntryExists: + except DbExperimentEntryExists: is_new = False else: try: return True, update_func(**update_data) - except ExperimentEntryNotFound: + except DbExperimentEntryNotFound: is_new = True - raise ExperimentError("Unable to determine the existence of the entry.") + raise DbExperimentDataError("Unable to determine the existence of the entry.") except Exception: # pylint: disable=broad-except # Don't fail the experiment just because its data cannot be saved. LOG.error("Unable to save the experiment data: %s", traceback.format_exc()) diff --git a/qiskit_experiments/stored_data/__init__.py b/qiskit_experiments/stored_data/__init__.py deleted file mode 100644 index e3ad311c44..0000000000 --- a/qiskit_experiments/stored_data/__init__.py +++ /dev/null @@ -1,53 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2021. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -""" -=================================================== -Stored Data (:mod:`qiskit_experiments.stored_data`) -=================================================== - -.. currentmodule:: qiskit_experiments.stored_data - -This module contains the classes used to define the data structure of -an experiment, including its data, metadata, analysis results, and figures. -The classes also provide an interface with a database service for storing -and retrieving experiment-related data. - -Classes -======= - -.. autosummary:: - :toctree: ../stubs/ - - StoredData - StoredDataV1 - AnalysisResult - AnalysisResultV1 - ExperimentService - ExperimentServiceV1 - - -Exceptions -========== - -.. autosummary:: - :toctree: ../stubs/ - - ExperimentError - ExperimentEntryNotFound - ExperimentEntryExists -""" - -from .stored_data import StoredData, StoredDataV1 -from .analysis_result import AnalysisResult, AnalysisResultV1 -from .experiment_service import ExperimentService, ExperimentServiceV1 -from .exceptions import ExperimentError, ExperimentEntryExists, ExperimentEntryNotFound diff --git a/test/stored_data/__init__.py b/test/database_service/__init__.py similarity index 91% rename from test/stored_data/__init__.py rename to test/database_service/__init__.py index 62b7e61d2b..e0bf9c8b4e 100644 --- a/test/stored_data/__init__.py +++ b/test/database_service/__init__.py @@ -10,4 +10,4 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Test cases for stored data module.""" +"""Test cases for database service modules.""" diff --git a/test/stored_data/test_analysisresult.py b/test/database_service/test_db_analysis_result.py similarity index 82% rename from test/stored_data/test_analysisresult.py rename to test/database_service/test_db_analysis_result.py index 30cc064d20..f4fe877757 100644 --- a/test/stored_data/test_analysisresult.py +++ b/test/database_service/test_db_analysis_result.py @@ -20,14 +20,14 @@ import numpy as np from qiskit.test import QiskitTestCase -from qiskit_experiments.stored_data import AnalysisResultV1 as AnalysisResult -from qiskit_experiments.stored_data.device_component import Qubit, Resonator, to_component -from qiskit_experiments.stored_data.experiment_service import ExperimentServiceV1 -from qiskit_experiments.stored_data.exceptions import ExperimentError +from qiskit_experiments.database_service import DbAnalysisResultV1 as DbAnalysisResult +from qiskit_experiments.database_service.device_component import Qubit, Resonator, to_component +from qiskit_experiments.database_service.database_service import DatabaseServiceV1 +from qiskit_experiments.database_service.exceptions import DbExperimentDataError -class TestAnalysisResult(QiskitTestCase): - """Test the AnalysisResult class.""" +class TestDbAnalysisResult(QiskitTestCase): + """Test the DbAnalysisResult class.""" def test_analysis_result_attributes(self): """Test analysis result attributes.""" @@ -39,7 +39,7 @@ def test_analysis_result_attributes(self): "quality": "Good", "verified": False, } - result = AnalysisResult(result_data={"foo": "bar"}, tags=["tag1", "tag2"], **attrs) + result = DbAnalysisResult(result_data={"foo": "bar"}, tags=["tag1", "tag2"], **attrs) self.assertEqual({"foo": "bar"}, result.data()) self.assertEqual(["tag1", "tag2"], result.tags()) for key, val in attrs.items(): @@ -47,14 +47,14 @@ def test_analysis_result_attributes(self): def test_save(self): """Test saving analysis result.""" - mock_service = mock.create_autospec(ExperimentServiceV1) + mock_service = mock.create_autospec(DatabaseServiceV1) result = self._new_analysis_result() result.save(service=mock_service) mock_service.create_analysis_result.assert_called_once() def test_auto_save(self): """Test auto saving.""" - mock_service = mock.create_autospec(ExperimentServiceV1) + mock_service = mock.create_autospec(DatabaseServiceV1) result = self._new_analysis_result(service=mock_service) result.auto_save = True result.save() @@ -75,25 +75,25 @@ def test_auto_save(self): def test_set_service_init(self): """Test setting service in init.""" - mock_service = mock.create_autospec(ExperimentServiceV1) + mock_service = mock.create_autospec(DatabaseServiceV1) result = self._new_analysis_result(service=mock_service) self.assertEqual(mock_service, result.service) def test_set_service_direct(self): """Test setting service directly.""" - mock_service = mock.create_autospec(ExperimentServiceV1) + mock_service = mock.create_autospec(DatabaseServiceV1) result = self._new_analysis_result() result.service = mock_service self.assertEqual(mock_service, result.service) - with self.assertRaises(ExperimentError): + with self.assertRaises(DbExperimentDataError): result.service = mock_service def test_set_service_save(self): """Test setting service when saving.""" - orig_service = mock.create_autospec(ExperimentServiceV1) + orig_service = mock.create_autospec(DatabaseServiceV1) result = self._new_analysis_result(service=orig_service) - new_service = mock.create_autospec(ExperimentServiceV1) + new_service = mock.create_autospec(DatabaseServiceV1) result.save(service=new_service) new_service.create_analysis_result.assert_called() orig_service.create_analysis_result.assert_not_called() @@ -137,9 +137,8 @@ def test_data_serialization(self): def test_source(self): """Test getting analysis result source.""" result = self._new_analysis_result() - source_vals = "\n".join([str(val) for val in result.source.values()]) - self.assertIn("AnalysisResultV1", source_vals) - self.assertIn("qiskit-terra", source_vals) + self.assertIn("DbAnalysisResultV1", result.source["class"]) + self.assertTrue(result.source["qiskit_version"]) def _new_analysis_result(self, **kwargs): """Return a new analysis result.""" @@ -150,7 +149,7 @@ def _new_analysis_result(self, **kwargs): "experiment_id": "1234", } values.update(kwargs) - return AnalysisResult(**values) + return DbAnalysisResult(**values) class TestDeviceComponent(QiskitTestCase): diff --git a/test/stored_data/test_stored_data.py b/test/database_service/test_db_experiment_data.py similarity index 83% rename from test/stored_data/test_stored_data.py rename to test/database_service/test_db_experiment_data.py index b76978be8a..ec618b0d9d 100644 --- a/test/stored_data/test_stored_data.py +++ b/test/database_service/test_db_experiment_data.py @@ -32,17 +32,17 @@ from qiskit.providers import JobV1 as Job from qiskit.providers import JobStatus from qiskit.tools.visualization import HAS_MATPLOTLIB -from qiskit_experiments.stored_data import StoredDataV1 as StoredData -from qiskit_experiments.stored_data import ExperimentServiceV1 -from qiskit_experiments.stored_data.exceptions import ( - ExperimentError, - ExperimentEntryNotFound, - ExperimentEntryExists, +from qiskit_experiments.database_service import DbExperimentDataV1 as DbExperimentData +from qiskit_experiments.database_service import DatabaseServiceV1 +from qiskit_experiments.database_service.exceptions import ( + DbExperimentDataError, + DbExperimentEntryNotFound, + DbExperimentEntryExists, ) -class TestStoredData(QiskitTestCase): - """Test the ExperimentData class.""" +class TestDbExperimentData(QiskitTestCase): + """Test the DbExperimentData class.""" def setUp(self): super().setUp() @@ -56,7 +56,7 @@ def test_stored_data_attributes(self): "figure_names": ["figure1"], "notes": "some notes", } - exp_data = StoredData( + exp_data = DbExperimentData( backend=self.backend, experiment_type="qiskit_test", experiment_id="1234", @@ -74,7 +74,7 @@ def test_stored_data_attributes(self): def test_add_data_dict(self): """Test add data in dictionary.""" - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") a_dict = {"counts": {"01": 518}} dicts = [{"counts": {"00": 284}}, {"counts": {"00": 14}}] @@ -84,7 +84,7 @@ def test_add_data_dict(self): def test_add_data_result(self): """Test add result data.""" - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") a_result = self._get_job_result(1) results = [self._get_job_result(2), self._get_job_result(3)] @@ -99,7 +99,7 @@ def test_add_data_result(self): def test_add_data_result_metadata(self): """Test add result metadata.""" - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") result1 = self._get_job_result(1, has_metadata=False) result2 = self._get_job_result(1, has_metadata=True) @@ -122,7 +122,7 @@ def test_add_data_job(self): for job in jobs: expected.extend(job.result().get_counts()) - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") exp_data.add_data(a_job) exp_data.add_data(jobs) exp_data.block_for_results() @@ -133,7 +133,7 @@ def test_add_data_job_callback(self): """Test add job data with callback.""" def _callback(_exp_data): - self.assertIsInstance(_exp_data, StoredData) + self.assertIsInstance(_exp_data, DbExperimentData) self.assertEqual( [dat["counts"] for dat in _exp_data.data()], a_job.result().get_counts() ) @@ -146,7 +146,7 @@ def _callback(_exp_data): a_job.result.return_value = self._get_job_result(2) called_back = False - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") exp_data.add_data(a_job, post_processing_callback=_callback) exp_data.block_for_results() self.assertTrue(called_back) @@ -155,7 +155,7 @@ def test_add_data_callback(self): """Test add data with callback.""" def _callback(_exp_data): - self.assertIsInstance(_exp_data, StoredData) + self.assertIsInstance(_exp_data, DbExperimentData) nonlocal called_back_count, expected_data, subtests expected_data.extend(subtests[called_back_count][1]) self.assertEqual([dat["counts"] for dat in _exp_data.data()], expected_data) @@ -175,7 +175,7 @@ def _callback(_exp_data): called_back_count = 0 expected_data = [] - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") for data, _ in subtests: with self.subTest(data=data): @@ -187,7 +187,7 @@ def test_add_data_job_callback_kwargs(self): """Test add job data with callback and additional arguments.""" def _callback(_exp_data, **kwargs): - self.assertIsInstance(_exp_data, StoredData) + self.assertIsInstance(_exp_data, DbExperimentData) self.assertEqual({"foo": callback_kwargs}, kwargs) nonlocal called_back called_back = True @@ -197,7 +197,7 @@ def _callback(_exp_data, **kwargs): called_back = False callback_kwargs = "foo" - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") exp_data.add_data(a_job, _callback, foo=callback_kwargs) exp_data.block_for_results() self.assertTrue(called_back) @@ -214,7 +214,7 @@ def _callback(_exp_data, **kwargs): event = threading.Event() self.addCleanup(event.set) - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") exp_data.add_data(a_job, _callback, event=event) with self.assertLogs("qiskit_experiments", "WARNING"): exp_data.add_data({"foo": "bar"}) @@ -226,7 +226,7 @@ def test_get_data(self): data1.append({"counts": {"00": randrange(1024)}}) results = self._get_job_result(3) - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") exp_data.add_data(data1) exp_data.add_data(results) self.assertEqual(data1[1], exp_data.data(1)) @@ -251,7 +251,7 @@ def test_add_figure(self): for name, figure, figure_name in sub_tests: with self.subTest(name=name): - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") fn = exp_data.add_figures(figure, figure_name) self.assertEqual(hello_bytes, exp_data.figure(fn)) @@ -265,7 +265,7 @@ def test_add_figure_plot(self): ax.plot([1, 2, 3]) service = self._set_mock_service() - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") exp_data.add_figures(figure, save_figure=True) self.assertEqual(figure, exp_data.figure(0)) service.create_figure.assert_called_once() @@ -289,7 +289,7 @@ def test_add_figures(self): for name, figures, figure_names in sub_tests: with self.subTest(name=name): - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") added_names = exp_data.add_figures(figures, figure_names) for idx, added_fn in enumerate(added_names): self.assertEqual(hello_bytes[idx], exp_data.figure(added_fn)) @@ -299,9 +299,9 @@ def test_add_figure_overwrite(self): hello_bytes = str.encode("hello world") friend_bytes = str.encode("hello friend!") - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") fn = exp_data.add_figures(hello_bytes) - with self.assertRaises(ExperimentEntryExists): + with self.assertRaises(DbExperimentEntryExists): exp_data.add_figures(friend_bytes, fn) exp_data.add_figures(friend_bytes, fn, overwrite=True) @@ -311,7 +311,7 @@ def test_add_figure_save(self): """Test saving a figure in the database.""" hello_bytes = str.encode("hello world") service = self._set_mock_service() - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") exp_data.add_figures(hello_bytes, save_figure=True) service.create_figure.assert_called_once() _, kwargs = service.create_figure.call_args @@ -320,12 +320,12 @@ def test_add_figure_save(self): def test_add_figure_bad_input(self): """Test adding figures with bad input.""" - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") self.assertRaises(ValueError, exp_data.add_figures, ["foo", "bar"], ["name"]) def test_get_figure(self): """Test getting figure.""" - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") figure_template = "hello world {}" name_template = "figure_{}" for idx in range(3): @@ -345,7 +345,7 @@ def test_get_figure(self): def test_delete_figure(self): """Test deleting a figure.""" - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") id_template = "figure_{}" for idx in range(3): exp_data.add_figures(str.encode("hello world"), id_template.format(idx)) @@ -355,11 +355,11 @@ def test_delete_figure(self): for del_key, figure_name in sub_tests: with self.subTest(del_key=del_key): exp_data.delete_figure(del_key) - self.assertRaises(ExperimentEntryNotFound, exp_data.figure, figure_name) + self.assertRaises(DbExperimentEntryNotFound, exp_data.figure, figure_name) def test_delayed_backend(self): """Test initializing experiment data without a backend.""" - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") self.assertIsNone(exp_data.backend) self.assertIsNone(exp_data.service) exp_data.save() @@ -370,7 +370,7 @@ def test_delayed_backend(self): def test_different_backend(self): """Test setting a different backend.""" - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") a_job = mock.create_autospec(Job, instance=True) self.assertNotEqual(exp_data.backend, a_job.backend()) with self.assertLogs("qiskit_experiments", "WARNING"): @@ -378,7 +378,7 @@ def test_different_backend(self): def test_add_get_analysis_result(self): """Test adding and getting analysis results.""" - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") results = [] for idx in range(5): res = mock.MagicMock() @@ -393,7 +393,7 @@ def test_add_get_analysis_result(self): def test_add_get_analysis_results(self): """Test adding and getting a list of analysis results.""" - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") results = [] for idx in range(5): res = mock.MagicMock() @@ -405,7 +405,7 @@ def test_add_get_analysis_results(self): def test_delete_analysis_result(self): """Test deleting analysis result.""" - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") id_template = "result_{}" for idx in range(3): res = mock.MagicMock() @@ -416,12 +416,12 @@ def test_delete_analysis_result(self): for del_key, res_id in subtests: with self.subTest(del_key=del_key): exp_data.delete_analysis_result(del_key) - self.assertRaises(ExperimentEntryNotFound, exp_data.analysis_result, res_id) + self.assertRaises(DbExperimentEntryNotFound, exp_data.analysis_result, res_id) def test_save(self): """Test saving experiment data.""" - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") - service = mock.create_autospec(ExperimentServiceV1, instance=True) + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") + service = mock.create_autospec(DatabaseServiceV1, instance=True) exp_data.save(service=service) service.create_experiment.assert_called_once() _, kwargs = service.create_experiment.call_args @@ -433,8 +433,8 @@ def test_save(self): def test_save_all(self): """Test saving all experiment related data.""" - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") - service = mock.create_autospec(ExperimentServiceV1, instance=True) + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") + service = mock.create_autospec(DatabaseServiceV1, instance=True) exp_data.add_figures(str.encode("hello world")) analysis_result = mock.MagicMock() exp_data.add_analysis_results(analysis_result) @@ -445,8 +445,8 @@ def test_save_all(self): def test_save_all_delete(self): """Test saving all deletion.""" - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") - service = mock.create_autospec(ExperimentServiceV1, instance=True) + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") + service = mock.create_autospec(DatabaseServiceV1, instance=True) exp_data.add_figures(str.encode("hello world")) exp_data.add_analysis_results(mock.MagicMock()) exp_data.delete_analysis_result(0) @@ -460,7 +460,7 @@ def test_save_all_delete(self): def test_set_service_backend(self): """Test setting service via backend.""" mock_service = self._set_mock_service() - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") self.assertEqual(mock_service, exp_data.service) def test_set_service_job(self): @@ -468,27 +468,27 @@ def test_set_service_job(self): mock_service = self._set_mock_service() job = mock.create_autospec(Job, instance=True) job.backend.return_value = self.backend - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") self.assertIsNone(exp_data.service) exp_data.add_data(job) self.assertEqual(mock_service, exp_data.service) def test_set_service_direct(self): """Test setting service directly.""" - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") self.assertIsNone(exp_data.service) mock_service = mock.MagicMock() exp_data.service = mock_service self.assertEqual(mock_service, exp_data.service) - with self.assertRaises(ExperimentError): + with self.assertRaises(DbExperimentDataError): exp_data.service = mock_service def test_set_service_save(self): """Test setting service when saving.""" orig_service = self._set_mock_service() - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") - new_service = mock.create_autospec(ExperimentServiceV1, instance=True) + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") + new_service = mock.create_autospec(DatabaseServiceV1, instance=True) exp_data.save(service=new_service) new_service.create_experiment.assert_called() orig_service.create_experiment.assert_not_called() @@ -496,7 +496,7 @@ def test_set_service_save(self): def test_new_backend_has_service(self): """Test changing backend doesn't change existing service.""" orig_service = self._set_mock_service() - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") self.assertEqual(orig_service, exp_data.service) job = mock.create_autospec(Job, instance=True) @@ -509,7 +509,7 @@ def test_new_backend_has_service(self): def test_auto_save(self): """Test auto save.""" service = self._set_mock_service() - exp_data = StoredData(backend=self.backend, experiment_type="qiskit_test") + exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") exp_data.auto_save = True mock_result = mock.MagicMock() @@ -547,7 +547,7 @@ def test_status_job_pending(self): job2.status.return_value = JobStatus.RUNNING self.addCleanup(event.set) - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") exp_data.add_data(job1) exp_data.add_data(job2, lambda *args, **kwargs: event.wait()) self.assertEqual("RUNNING", exp_data.status()) @@ -561,7 +561,7 @@ def test_status_job_error(self): job2 = mock.create_autospec(Job, instance=True) job2.status.return_value = JobStatus.ERROR - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") exp_data.add_data([job1, job2]) self.assertEqual("ERROR", exp_data.status()) @@ -573,7 +573,7 @@ def test_status_post_processing(self): event = threading.Event() self.addCleanup(event.set) - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") exp_data.add_data(job) exp_data.add_data(job, lambda *args, **kwargs: event.wait()) self.assertEqual("POST_PROCESSING", exp_data.status()) @@ -587,7 +587,7 @@ def _post_processing(*args, **kwargs): job = mock.create_autospec(Job, instance=True) job.result.return_value = self._get_job_result(3) - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") exp_data.add_data(job) exp_data.add_data(job, _post_processing) exp_data.block_for_results() @@ -597,7 +597,7 @@ def test_status_done(self): """Test experiment status when all jobs are done.""" job = mock.create_autospec(Job, instance=True) job.result.return_value = self._get_job_result(3) - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") exp_data.add_data(job) exp_data.add_data(job, lambda *args, **kwargs: time.sleep(1)) exp_data.block_for_results() @@ -605,21 +605,21 @@ def test_status_done(self): def test_update_tags(self): """Test updating experiment tags.""" - exp_data = StoredData(experiment_type="qiskit_test", tags=["foo"]) + exp_data = DbExperimentData(experiment_type="qiskit_test", tags=["foo"]) self.assertEqual(["foo"], exp_data.tags()) exp_data.update_tags(["bar"]) self.assertEqual(["bar"], exp_data.tags()) def test_update_metadata(self): """Test updating experiment metadata.""" - exp_data = StoredData(experiment_type="qiskit_test", metadata={"foo": "bar"}) + exp_data = DbExperimentData(experiment_type="qiskit_test", metadata={"foo": "bar"}) self.assertEqual({"foo": "bar"}, exp_data.metadata()) exp_data.update_metadata({"bar": "foo"}) self.assertEqual({"bar": "foo"}, exp_data.metadata()) def test_cancel_jobs(self): """Test canceling experiment jobs.""" - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") event = threading.Event() self.addCleanup(event.set) job = mock.create_autospec(Job, instance=True) @@ -631,12 +631,12 @@ def test_cancel_jobs(self): def test_metadata_serialization(self): """Test experiment metadata serialization.""" metadata = {"complex": 2 + 3j, "numpy": np.zeros(2)} - exp_data = StoredData(experiment_type="qiskit_test", metadata=metadata) + exp_data = DbExperimentData(experiment_type="qiskit_test", metadata=metadata) serialized = exp_data.serialize_metadata() self.assertIsInstance(serialized, str) self.assertTrue(json.loads(serialized)) - deserialized = StoredData.deserialize_metadata(serialized) + deserialized = DbExperimentData.deserialize_metadata(serialized) self.assertEqual(metadata["complex"], deserialized["complex"]) self.assertEqual(metadata["numpy"].all(), deserialized["numpy"].all()) @@ -653,7 +653,7 @@ def _post_processing(*args, **kwargs): # pylint: disable=unused-argument job2.status.return_value = JobStatus.ERROR job2.job_id.return_value = "5678" - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") exp_data.add_data(job1, _post_processing) exp_data.add_data(job2) exp_data.block_for_results() @@ -662,8 +662,8 @@ def _post_processing(*args, **kwargs): # pylint: disable=unused-argument def test_source(self): """Test getting experiment source.""" - exp_data = StoredData(experiment_type="qiskit_test") - self.assertIn("StoredDataV1", exp_data.source["class"]) + exp_data = DbExperimentData(experiment_type="qiskit_test") + self.assertIn("DbExperimentDataV1", exp_data.source["class"]) self.assertTrue(exp_data.source["qiskit_version"]) def test_block_for_jobs(self): @@ -678,19 +678,19 @@ def _sleeper(*args, **kwargs): # pylint: disable=unused-argument sleep_count = 0 job = mock.create_autospec(Job, instance=True) job.result = _sleeper - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") exp_data.add_data(job, _sleeper) exp_data.block_for_results() self.assertEqual(2, sleep_count) def test_additional_attr(self): """Test additional experiment attributes.""" - exp_data = StoredData(experiment_type="qiskit_test", foo="foo") + exp_data = DbExperimentData(experiment_type="qiskit_test", foo="foo") self.assertEqual("foo", exp_data.foo) def test_str(self): """Test the string representation.""" - exp_data = StoredData(experiment_type="qiskit_test") + exp_data = DbExperimentData(experiment_type="qiskit_test") exp_data.add_data(self._get_job_result(1)) result = mock.MagicMock() exp_data.add_analysis_results(result) @@ -725,6 +725,6 @@ def _set_mock_service(self): """Add a mock service to the backend.""" mock_provider = mock.MagicMock() self.backend._provider = mock_provider - mock_service = mock.create_autospec(ExperimentServiceV1, instance=True) + mock_service = mock.create_autospec(DatabaseServiceV1, instance=True) mock_provider.service.return_value = mock_service return mock_service From 9070a8958bcbd5f4b90d3b36f3dce60550e62fe7 Mon Sep 17 00:00:00 2001 From: jessieyu Date: Mon, 28 Jun 2021 17:35:17 -0400 Subject: [PATCH 12/20] review comments --- .../database_service/database_service.py | 7 +++- .../database_service/db_analysis_result.py | 6 ++-- .../database_service/db_experiment_data.py | 34 ++++++++++++++----- .../test_db_analysis_result.py | 16 ++++----- 4 files changed, 42 insertions(+), 21 deletions(-) diff --git a/qiskit_experiments/database_service/database_service.py b/qiskit_experiments/database_service/database_service.py index f9680d725b..5d12e1a8cf 100644 --- a/qiskit_experiments/database_service/database_service.py +++ b/qiskit_experiments/database_service/database_service.py @@ -393,7 +393,7 @@ def set_options(self, **fields): for field in fields: if field not in self._options: raise AttributeError("Options field %s is not valid for this " "service." % field) - self._options.update(**fields) + self._options.update_options(**fields) def option(self, field: str) -> Any: """Get the value of the specified option. @@ -410,3 +410,8 @@ def option(self, field: str) -> Any: if field not in self._options: raise AttributeError(f"Options field {field} is not valid for this service.") return self._options[field] + + @property + def options(self) -> Options: + """Return the options for the service.""" + return self._options diff --git a/qiskit_experiments/database_service/db_analysis_result.py b/qiskit_experiments/database_service/db_analysis_result.py index 40fbefa088..d451626e4e 100644 --- a/qiskit_experiments/database_service/db_analysis_result.py +++ b/qiskit_experiments/database_service/db_analysis_result.py @@ -231,8 +231,8 @@ def data(self) -> Dict: return self._result_data @auto_save - def update_data(self, new_data: Dict) -> None: - """Update result data. + def set_data(self, new_data: Dict) -> None: + """Set result data. Args: new_data: New analysis result data. @@ -244,7 +244,7 @@ def tags(self): return self._tags @auto_save - def update_tags(self, new_tags: List[str]) -> None: + def set_tags(self, new_tags: List[str]) -> None: """Set tags for this result. Args: diff --git a/qiskit_experiments/database_service/db_experiment_data.py b/qiskit_experiments/database_service/db_experiment_data.py index 845be84893..c46182573e 100644 --- a/qiskit_experiments/database_service/db_experiment_data.py +++ b/qiskit_experiments/database_service/db_experiment_data.py @@ -168,12 +168,13 @@ def _set_service_from_backend(self, backend: Union[Backend, BaseBackend]) -> Non """ with contextlib.suppress(Exception): self._service = backend.provider().service("experiment") - self.auto_save = self._service.option("auto_save") + self.auto_save = self._service.options.get("auto_save", False) def add_data( self, data: Union[Result, List[Result], Job, List[Job], Dict, List[Dict]], post_processing_callback: Optional[Callable] = None, + timeout: Optional[float] = None, **kwargs: Any, ) -> None: """Add experiment data. @@ -205,6 +206,8 @@ def add_data( * This ``DbExperimentData`` object. * Additional keyword arguments passed to this method. + timeout: Timeout waiting for job to finish, if `data` is a ``Job``. + **kwargs: Keyword arguments to be passed to the callback function. Raises: @@ -236,7 +239,7 @@ def add_data( ( data, self._executor.submit( - self._wait_for_job, data, post_processing_callback, **kwargs + self._wait_for_job, data, post_processing_callback, timeout, **kwargs ), ) ) @@ -261,6 +264,7 @@ def _wait_for_job( self, job: Union[Job, BaseJob], job_done_callback: Optional[Callable] = None, + timeout: Optional[float] = None, **kwargs: Any, ) -> None: """Wait for a job to finish. @@ -268,6 +272,7 @@ def _wait_for_job( Args: job: Job to wait for. job_done_callback: Callback function to invoke when job finishes. + timeout: Timeout waiting for job to finish. **kwargs: Keyword arguments to be passed to the callback function. Raises: @@ -275,7 +280,10 @@ def _wait_for_job( """ LOG.debug("Waiting for job %s to finish.", job.job_id()) try: - job_result = job.result() + try: + job_result = job.result(timeout=timeout) + except TypeError: # Not all jobs take timeout. + job_result = job.result() with self._data.lock: # Hold the lock so we add the block of results together. self._add_result_data(job_result) @@ -307,6 +315,10 @@ def _add_result_data(self, result: Result) -> None: expr_result = result.results[i] if hasattr(expr_result, "header") and hasattr(expr_result.header, "metadata"): data["metadata"] = expr_result.header.metadata + data["shots"] = expr_result.shots + data["meas_level"] = expr_result.meas_level + if hasattr(expr_result, "meas_return"): + data["meas_return"] = expr_result.meas_return self._add_single_data(data) def _add_single_data(self, data: Dict[str, any]) -> None: @@ -775,12 +787,16 @@ def cancel_jobs(self) -> None: except Exception as err: # pylint: disable=broad-except LOG.info("Unable to cancel job %s: %s", job.job_id(), err) - def block_for_results(self) -> None: - """Block until all pending jobs and their post processing finish.""" + def block_for_results(self, timeout: Optional[float] = None) -> None: + """Block until all pending jobs and their post processing finish. + + Args: + timeout: Timeout waiting for results. + """ for job, fut in self._job_futures.copy(): LOG.info("Waiting for job %s and its post processing to finish.", job.job_id()) with contextlib.suppress(Exception): - fut.result() + fut.result(timeout) def status(self) -> str: """Return the data processing status. @@ -960,8 +976,8 @@ def share_level(self, new_level: str) -> None: Args: new_level: New experiment share level. Valid share levels are provider- - specified. For example, IBMQ allows "global", "hub", "group", - "project", and "private". + specified. For example, IBM Quantum experiment service allows + "global", "hub", "group", "project", and "private". """ self._share_level = new_level if self.auto_save: @@ -1010,7 +1026,7 @@ def service(self, service: "DatabaseServiceV1") -> None: raise DbExperimentDataError("An experiment service is already being used.") self._service = service with contextlib.suppress(Exception): - self.auto_save = self._service.option("auto_save") + self.auto_save = self._service.options.get("auto_save", False) @property def source(self) -> Dict: diff --git a/test/database_service/test_db_analysis_result.py b/test/database_service/test_db_analysis_result.py index f4fe877757..e688f57aab 100644 --- a/test/database_service/test_db_analysis_result.py +++ b/test/database_service/test_db_analysis_result.py @@ -61,8 +61,8 @@ def test_auto_save(self): subtests = [ # update function, update parameters, service called - (result.update_tags, (["foo"],)), - (result.update_data, ({"foo": "bar"},)), + (result.set_tags, (["foo"],)), + (result.set_data, ({"foo": "bar"},)), (setattr, (result, "quality", "GOOD")), (setattr, (result, "verified", True)), ] @@ -98,16 +98,16 @@ def test_set_service_save(self): new_service.create_analysis_result.assert_called() orig_service.create_analysis_result.assert_not_called() - def test_update_data(self): - """Test updating data.""" + def test_set_data(self): + """Test setting data.""" result = self._new_analysis_result() - result.update_data({"foo": "new data"}) + result.set_data({"foo": "new data"}) self.assertEqual({"foo": "new data"}, result.data()) - def test_update_tags(self): - """Test updating tags.""" + def test_set_tags(self): + """Test setting tags.""" result = self._new_analysis_result() - result.update_tags(["new_tag"]) + result.set_tags(["new_tag"]) self.assertEqual(["new_tag"], result.tags()) def test_update_quality(self): From 5205ee6f81b85a5fb0ceaca8bd51a75ec22a9849 Mon Sep 17 00:00:00 2001 From: jessieyu Date: Mon, 28 Jun 2021 18:15:40 -0400 Subject: [PATCH 13/20] fix lint --- .../database_service/db_analysis_result.py | 4 ++-- .../database_service/db_experiment_data.py | 12 ++++++------ qiskit_experiments/database_service/utils.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/qiskit_experiments/database_service/db_analysis_result.py b/qiskit_experiments/database_service/db_analysis_result.py index 63d4b86e8a..460914de93 100644 --- a/qiskit_experiments/database_service/db_analysis_result.py +++ b/qiskit_experiments/database_service/db_analysis_result.py @@ -189,7 +189,7 @@ def save(self, service: Optional["DatabaseServiceV1"] = None) -> None: If ``None``, the default, if any, is used. Raises: - ExperimentError: If the analysis result contains invalid data. + DbExperimentDataError: If the analysis result contains invalid data. """ service = service or self._service if not service: @@ -345,7 +345,7 @@ def service(self, service: "DatabaseServiceV1") -> None: service: Service to be used. Raises: - ExperimentError: If an experiment service is already being used. + DbExperimentDataError: If an experiment service is already being used. """ if self._service: raise DbExperimentDataError("An experiment service is already being used.") diff --git a/qiskit_experiments/database_service/db_experiment_data.py b/qiskit_experiments/database_service/db_experiment_data.py index c46182573e..08c4ad11c1 100644 --- a/qiskit_experiments/database_service/db_experiment_data.py +++ b/qiskit_experiments/database_service/db_experiment_data.py @@ -394,7 +394,7 @@ def add_figures( Figure names. Raises: - ExperimentEntryExists: If the figure with the same name already exists, + DbExperimentEntryNotFound: If the figure with the same name already exists, and `overwrite=True` is not specified. ValueError: If an input parameter has an invalid value. """ @@ -481,7 +481,7 @@ def delete_figure( Figure name. Raises: - ExperimentEntryNotFound: If the figure is not found. + DbExperimentEntryNotFound: If the figure is not found. """ if isinstance(figure_key, int): figure_key = self._figures.keys()[figure_key] @@ -514,7 +514,7 @@ def figure( content of the figure in bytes. Raises: - ExperimentEntryNotFound: If the figure cannot be found. + DbExperimentEntryNotFound: If the figure cannot be found. """ if isinstance(figure_key, int): figure_key = self._figures.keys()[figure_key] @@ -577,7 +577,7 @@ def delete_analysis_result( Analysis result ID. Raises: - ExperimentEntryNotFound: If analysis result not found. + DbExperimentEntryNotFound: If analysis result not found. """ if isinstance(result_key, int): result_key = self._analysis_results.keys()[result_key] @@ -616,7 +616,7 @@ def analysis_result( Raises: TypeError: If the input `index` has an invalid type. - ExperimentEntryNotFound: If the entry cannot be found. + DbExperimentEntryNotFound: If the entry cannot be found. """ if self.service and (not self._analysis_results or refresh): retrieved_results = self.service.analysis_results( @@ -1020,7 +1020,7 @@ def service(self, service: "DatabaseServiceV1") -> None: service: Service to be used. Raises: - ExperimentError: If an experiment service is already being used. + DbExperimentDataError: If an experiment service is already being used. """ if self._service: raise DbExperimentDataError("An experiment service is already being used.") diff --git a/qiskit_experiments/database_service/utils.py b/qiskit_experiments/database_service/utils.py index 919c0fd753..8c0322f382 100644 --- a/qiskit_experiments/database_service/utils.py +++ b/qiskit_experiments/database_service/utils.py @@ -110,7 +110,7 @@ def save_data( A tuple of whether the data was saved and the function return value. Raises: - ExperimentError: If unable to determine whether the entry exists. + DbExperimentDataError: If unable to determine whether the entry exists. """ attempts = 0 try: From e916c5f5f65c81faefcd713703ad32ade370ce3e Mon Sep 17 00:00:00 2001 From: jessieyu Date: Wed, 30 Jun 2021 16:47:32 -0400 Subject: [PATCH 14/20] review comments --- .../database_service/__init__.py | 41 +++++++++++++++++++ .../database_service/db_experiment_data.py | 10 +++-- .../test_db_experiment_data.py | 24 +++++------ 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/qiskit_experiments/database_service/__init__.py b/qiskit_experiments/database_service/__init__.py index eb5d298962..4b6b8f7741 100644 --- a/qiskit_experiments/database_service/__init__.py +++ b/qiskit_experiments/database_service/__init__.py @@ -22,6 +22,47 @@ well as the interface to an experiment database service. An experiment database service allows one to store, retrieve, and query experiment related data. +:class:`DbExperimentDataV1` is the main class that defines the structure of +experiment data, which consists of the following:: + + * Results from circuit execution, which is called ``data`` in this class. + The :meth:`DbExperimentDataV1.add_data` + method allows you to add circuit jobs and job results. If jobs are added, + the method asynchronously waits for them to finish and extracts job results. + :meth:`DbExperimentDataV1.data` can then be used to retrieve this data. + Note that this data is not saved in the database. It is included in this + class only for convenience. + + * Experiment metadata. This is a freeform keyword-value dictionary. You can + use this to save extra information, such as the physical qubits the experiment + operated on, in the database. :meth:`DbExperimentDataV1.set_metadata` and + :meth:`DbExperimentDataV1.metadata` are methods to set and retrieve metadata, + respectively. + + * Analysis results. It is likely that some analysis is to be done on the + experiment data once the circuit jobs finish, and the result of this + analysis can be stored in the database. Similar to ``DbExperimentDataV1``, + :class:`DbAnalysisResultV1` defines the data structure of an analysis + result and provides methods to interface with the database. Being a separate + class, :class:`DbAnalysisResultV1` allows you to modify an analysis result + without modifying the experiment data. + + * Figures. Some analysis functions also generate figures, which can also be + saved in the database. + +:class:`DatabaseServiceV1` provides low-level abstract interface for accessing the +database, such as :meth:`DatabaseServiceV1.create_experiment` for creating a +new experiment entry and :meth:`DatabaseServiceV1.update_experiment` for +updating an existing entry. :class:`DbExperimentDataV1` has methods that wrap +around some of these low-level database methods. For example, +:meth:`DbExperimentDataV1.save` calls :meth:`DatabaseServiceV1.create_experiment` +under the cover to save experiment related data. The low-level methods are only +expected to be used when you want to interact with the database directly - for +example, to retrieve a saved analysis result. + +Currently only IBM Quantum provides this database service, but we plan on having +a local database service in the near future. + Classes ======= diff --git a/qiskit_experiments/database_service/db_experiment_data.py b/qiskit_experiments/database_service/db_experiment_data.py index 08c4ad11c1..0bcb3c4840 100644 --- a/qiskit_experiments/database_service/db_experiment_data.py +++ b/qiskit_experiments/database_service/db_experiment_data.py @@ -312,6 +312,8 @@ def _add_result_data(self, result: Result) -> None: if "counts" in data: # Format to Counts object rather than hex dict data["counts"] = result.get_counts(i) + if "memory" in data: + data["memory"] = result.get_memory(i) expr_result = result.results[i] if hasattr(expr_result, "header") and hasattr(expr_result.header, "metadata"): data["metadata"] = expr_result.header.metadata @@ -595,7 +597,7 @@ def delete_analysis_result( return result_key - def analysis_result( + def analysis_results( self, index: Optional[Union[int, slice, str]] = None, refresh: bool = False ) -> Union[DbAnalysisResult, List[DbAnalysisResult]]: """Return analysis results associated with this experiment. @@ -881,7 +883,7 @@ def tags(self) -> List[str]: return self._tags @auto_save - def update_tags(self, new_tags: List[str]) -> None: + def set_tags(self, new_tags: List[str]) -> None: """Set tags for this experiment. Args: @@ -898,8 +900,8 @@ def metadata(self) -> Dict: return self._metadata @auto_save - def update_metadata(self, metadata: Dict) -> None: - """Update metadata for this experiment. + def set_metadata(self, metadata: Dict) -> None: + """Set metadata for this experiment. Args: metadata: New metadata for the experiment. diff --git a/test/database_service/test_db_experiment_data.py b/test/database_service/test_db_experiment_data.py index ec618b0d9d..4f77f20473 100644 --- a/test/database_service/test_db_experiment_data.py +++ b/test/database_service/test_db_experiment_data.py @@ -386,10 +386,10 @@ def test_add_get_analysis_result(self): results.append(res) exp_data.add_analysis_results(res) - self.assertEqual(results, exp_data.analysis_result()) - self.assertEqual(results[1], exp_data.analysis_result(1)) - self.assertEqual(results[2:4], exp_data.analysis_result(slice(2, 4))) - self.assertEqual(results[4], exp_data.analysis_result(results[4].result_id)) + self.assertEqual(results, exp_data.analysis_results()) + self.assertEqual(results[1], exp_data.analysis_results(1)) + self.assertEqual(results[2:4], exp_data.analysis_results(slice(2, 4))) + self.assertEqual(results[4], exp_data.analysis_results(results[4].result_id)) def test_add_get_analysis_results(self): """Test adding and getting a list of analysis results.""" @@ -401,7 +401,7 @@ def test_add_get_analysis_results(self): results.append(res) exp_data.add_analysis_results(results) - self.assertEqual(results, exp_data.analysis_result()) + self.assertEqual(results, exp_data.analysis_results()) def test_delete_analysis_result(self): """Test deleting analysis result.""" @@ -416,7 +416,7 @@ def test_delete_analysis_result(self): for del_key, res_id in subtests: with self.subTest(del_key=del_key): exp_data.delete_analysis_result(del_key) - self.assertRaises(DbExperimentEntryNotFound, exp_data.analysis_result, res_id) + self.assertRaises(DbExperimentEntryNotFound, exp_data.analysis_results, res_id) def test_save(self): """Test saving experiment data.""" @@ -521,8 +521,8 @@ def test_auto_save(self): (exp_data.add_figures, (str.encode("hello world"),), service.create_figure), (exp_data.delete_figure, (0,), service.delete_figure), (exp_data.delete_analysis_result, (0,), service.delete_analysis_result), - (exp_data.update_tags, (["foo"],), None), - (exp_data.update_metadata, ({"foo": "bar"},), None), + (exp_data.set_tags, (["foo"],), None), + (exp_data.set_metadata, ({"foo": "bar"},), None), (setattr, (exp_data, "notes", "foo"), None), (setattr, (exp_data, "share_level", "hub"), None), ] @@ -603,18 +603,18 @@ def test_status_done(self): exp_data.block_for_results() self.assertEqual("DONE", exp_data.status()) - def test_update_tags(self): + def test_set_tags(self): """Test updating experiment tags.""" exp_data = DbExperimentData(experiment_type="qiskit_test", tags=["foo"]) self.assertEqual(["foo"], exp_data.tags()) - exp_data.update_tags(["bar"]) + exp_data.set_tags(["bar"]) self.assertEqual(["bar"], exp_data.tags()) - def test_update_metadata(self): + def test_set_metadata(self): """Test updating experiment metadata.""" exp_data = DbExperimentData(experiment_type="qiskit_test", metadata={"foo": "bar"}) self.assertEqual({"foo": "bar"}, exp_data.metadata()) - exp_data.update_metadata({"bar": "foo"}) + exp_data.set_metadata({"bar": "foo"}) self.assertEqual({"bar": "foo"}, exp_data.metadata()) def test_cancel_jobs(self): From 7169edd8ee14f31ba1e9c1abee8d606d58da5387 Mon Sep 17 00:00:00 2001 From: jessieyu Date: Wed, 30 Jun 2021 16:57:31 -0400 Subject: [PATCH 15/20] fix lint --- qiskit_experiments/database_service/db_experiment_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit_experiments/database_service/db_experiment_data.py b/qiskit_experiments/database_service/db_experiment_data.py index 0bcb3c4840..d436316004 100644 --- a/qiskit_experiments/database_service/db_experiment_data.py +++ b/qiskit_experiments/database_service/db_experiment_data.py @@ -396,7 +396,7 @@ def add_figures( Figure names. Raises: - DbExperimentEntryNotFound: If the figure with the same name already exists, + DbExperimentEntryExists: If the figure with the same name already exists, and `overwrite=True` is not specified. ValueError: If an input parameter has an invalid value. """ From 111c7add8fbf5c5f9e8669ceb0c7fcca9a6a6528 Mon Sep 17 00:00:00 2001 From: jessieyu Date: Wed, 30 Jun 2021 17:42:28 -0400 Subject: [PATCH 16/20] add db service to doc --- docs/apidocs/database_service.rst | 6 ++++++ docs/apidocs/index.rst | 1 + 2 files changed, 7 insertions(+) create mode 100644 docs/apidocs/database_service.rst diff --git a/docs/apidocs/database_service.rst b/docs/apidocs/database_service.rst new file mode 100644 index 0000000000..b7be621bef --- /dev/null +++ b/docs/apidocs/database_service.rst @@ -0,0 +1,6 @@ +.. _qiskit-experiments: + +.. automodule:: qiskit_experiments.database_service + :no-members: + :no-inherited-members: + :no-special-members: \ No newline at end of file diff --git a/docs/apidocs/index.rst b/docs/apidocs/index.rst index c6f43eb75a..732e3874ee 100644 --- a/docs/apidocs/index.rst +++ b/docs/apidocs/index.rst @@ -14,4 +14,5 @@ Qiskit Experiments API Reference tomography analysis data_processing + database_service \ No newline at end of file From ede3a311fe1dcd1e58c34e8f43a95b3d930c0fc2 Mon Sep 17 00:00:00 2001 From: jessieyu Date: Wed, 30 Jun 2021 18:30:33 -0400 Subject: [PATCH 17/20] fix doc --- .../database_service/db_analysis_result.py | 9 +++-- .../database_service/db_experiment_data.py | 39 ++++++++++--------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/qiskit_experiments/database_service/db_analysis_result.py b/qiskit_experiments/database_service/db_analysis_result.py index 460914de93..0da8ae11b6 100644 --- a/qiskit_experiments/database_service/db_analysis_result.py +++ b/qiskit_experiments/database_service/db_analysis_result.py @@ -19,6 +19,7 @@ import copy from functools import wraps +import qiskit_experiments.database_service as database_service # pylint: disable=unused-import from .json import NumpyEncoder, NumpyDecoder from .utils import save_data, qiskit_version from .exceptions import DbExperimentDataError @@ -76,7 +77,7 @@ def __init__( quality: Optional[str] = None, verified: bool = False, tags: Optional[List[str]] = None, - service: Optional["DatabaseServiceV1"] = None, + service: Optional["database_service.DatabaseServiceV1"] = None, **kwargs, ): """AnalysisResult constructor. @@ -181,7 +182,7 @@ def from_data( **kwargs, ) - def save(self, service: Optional["DatabaseServiceV1"] = None) -> None: + def save(self, service: Optional["database_service.DatabaseServiceV1"] = None) -> None: """Save this analysis result in the database. Args: @@ -328,7 +329,7 @@ def experiment_id(self) -> str: return self._experiment_id @property - def service(self) -> Optional["DatabaseServiceV1"]: + def service(self) -> Optional["database_service.DatabaseServiceV1"]: """Return the database service. Returns: @@ -338,7 +339,7 @@ def service(self) -> Optional["DatabaseServiceV1"]: return self._service @service.setter - def service(self, service: "DatabaseServiceV1") -> None: + def service(self, service: "database_service.DatabaseServiceV1") -> None: """Set the service to be used for storing result data in a database. Args: diff --git a/qiskit_experiments/database_service/db_experiment_data.py b/qiskit_experiments/database_service/db_experiment_data.py index d436316004..78b916c781 100644 --- a/qiskit_experiments/database_service/db_experiment_data.py +++ b/qiskit_experiments/database_service/db_experiment_data.py @@ -30,6 +30,7 @@ from qiskit.providers.exceptions import JobError from qiskit.visualization import HAS_MATPLOTLIB +import qiskit_experiments.database_service as database_service # pylint: disable=unused-import from .exceptions import DbExperimentDataError, DbExperimentEntryNotFound, DbExperimentEntryExists from .db_analysis_result import DbAnalysisResultV1 as DbAnalysisResult from .json import NumpyEncoder, NumpyDecoder @@ -372,28 +373,30 @@ def data(self, index: Optional[Union[int, slice, str]] = None) -> Union[Dict, Li @auto_save def add_figures( self, - figures: Union[List[Union[str, bytes, "pyplot.Figure"]], str, bytes, "pyplot.Figure"], - figure_names: Optional[Union[List[str], str]] = None, - overwrite: bool = False, - save_figure: Optional[bool] = None, - service: Optional["DatabaseServiceV1"] = None, + figures, + figure_names=None, + overwrite=False, + save_figure=None, + service=None, ) -> Union[str, List[str]]: """Add the experiment figure. Args: - figures: Names of the figure files or figure data. - figure_names: Names of the figures. If ``None``, use the figure file + figures (str or bytes or pyplot.Figure or list): Names of the figure + files or figure data. + figure_names (str or list): Names of the figures. If ``None``, use the figure file names, if given, or a generated name. If `figures` is a list, then `figure_names` must also be a list of the same length or ``None``. - overwrite: Whether to overwrite the figure if one already exists with + overwrite (bool): Whether to overwrite the figure if one already exists with the same name. - save_figure: Whether to save the figure in the database. If ``None``, + save_figure (bool): Whether to save the figure in the database. If ``None``, the ``auto-save`` attribute is used. - service: Experiment service to be used to update the database, if + service (DatabaseServiceV1): Experiment service to be used to update the database, if the figure is to be uploaded. If ``None``, the default service is used. Returns: - Figure names. + str or list: + Figure names. Raises: DbExperimentEntryExists: If the figure with the same name already exists, @@ -470,7 +473,7 @@ def add_figures( def delete_figure( self, figure_key: Union[str, int], - service: Optional["DatabaseServiceV1"] = None, + service: Optional["database_service.DatabaseServiceV1"] = None, ) -> str: """Add the experiment figure. @@ -541,7 +544,7 @@ def figure( def add_analysis_results( self, results: Union[DbAnalysisResult, List[DbAnalysisResult]], - service: "DatabaseServiceV1" = None, + service: "database_service.DatabaseServiceV1" = None, ) -> None: """Save the analysis result. @@ -566,7 +569,7 @@ def add_analysis_results( @auto_save def delete_analysis_result( - self, result_key: Union[int, str], service: "DatabaseServiceV1" = None + self, result_key: Union[int, str], service: "database_service.DatabaseServiceV1" = None ) -> str: """Delete the analysis result. @@ -638,7 +641,7 @@ def analysis_results( raise TypeError(f"Invalid index type {type(index)}.") - def save(self, service: Optional["DatabaseServiceV1"] = None) -> None: + def save(self, service: Optional["database_service.DatabaseServiceV1"] = None) -> None: """Save this experiment in the database. Note: @@ -685,7 +688,7 @@ def save(self, service: Optional["DatabaseServiceV1"] = None) -> None: update_data=update_data, ) - def save_all(self, service: Optional["DatabaseServiceV1"] = None) -> None: + def save_all(self, service: Optional["database_service.DatabaseServiceV1"] = None) -> None: """Save this experiment and its analysis results and figures in the database. Note: @@ -1006,7 +1009,7 @@ def notes(self, new_notes: str) -> None: self.save() @property - def service(self) -> Optional["DatabaseServiceV1"]: + def service(self) -> Optional["database_service.DatabaseServiceV1"]: """Return the database service. Returns: @@ -1015,7 +1018,7 @@ def service(self) -> Optional["DatabaseServiceV1"]: return self._service @service.setter - def service(self, service: "DatabaseServiceV1") -> None: + def service(self, service: "database_service.DatabaseServiceV1") -> None: """Set the service to be used for storing experiment data. Args: From 665339655c86bf0230ec40047da66d86d168125c Mon Sep 17 00:00:00 2001 From: jessieyu Date: Thu, 1 Jul 2021 10:18:32 -0400 Subject: [PATCH 18/20] remove extra service keyword --- .../database_service/db_analysis_result.py | 13 ++-- .../database_service/db_experiment_data.py | 62 +++++++------------ .../test_db_analysis_result.py | 12 +--- .../test_db_experiment_data.py | 20 +++--- 4 files changed, 34 insertions(+), 73 deletions(-) diff --git a/qiskit_experiments/database_service/db_analysis_result.py b/qiskit_experiments/database_service/db_analysis_result.py index 0da8ae11b6..b48fd2cefb 100644 --- a/qiskit_experiments/database_service/db_analysis_result.py +++ b/qiskit_experiments/database_service/db_analysis_result.py @@ -182,18 +182,13 @@ def from_data( **kwargs, ) - def save(self, service: Optional["database_service.DatabaseServiceV1"] = None) -> None: + def save(self) -> None: """Save this analysis result in the database. - Args: - service: Experiment service to be used to save the data. - If ``None``, the default, if any, is used. - Raises: DbExperimentDataError: If the analysis result contains invalid data. """ - service = service or self._service - if not service: + if not self._service: LOG.warning( "Analysis result cannot be saved because no experiment service is available." ) @@ -217,8 +212,8 @@ def save(self, service: Optional["database_service.DatabaseServiceV1"] = None) - self._created_in_db, _ = save_data( is_new=(not self._created_in_db), - new_func=service.create_analysis_result, - update_func=service.update_analysis_result, + new_func=self._service.create_analysis_result, + update_func=self._service.update_analysis_result, new_data=new_data, update_data=update_data, ) diff --git a/qiskit_experiments/database_service/db_experiment_data.py b/qiskit_experiments/database_service/db_experiment_data.py index 78b916c781..03b98761fe 100644 --- a/qiskit_experiments/database_service/db_experiment_data.py +++ b/qiskit_experiments/database_service/db_experiment_data.py @@ -377,7 +377,6 @@ def add_figures( figure_names=None, overwrite=False, save_figure=None, - service=None, ) -> Union[str, List[str]]: """Add the experiment figure. @@ -391,8 +390,6 @@ def add_figures( the same name. save_figure (bool): Whether to save the figure in the database. If ``None``, the ``auto-save`` attribute is used. - service (DatabaseServiceV1): Experiment service to be used to update the database, if - the figure is to be uploaded. If ``None``, the default service is used. Returns: str or list: @@ -444,9 +441,8 @@ def add_figures( self._figures[fig_name] = figure - service = service or self._service save = save_figure if save_figure is not None else self.auto_save - if save and service: + if save and self._service: if HAS_MATPLOTLIB: # pylint: disable=import-error from matplotlib import pyplot @@ -460,8 +456,8 @@ def add_figures( } save_data( is_new=(not existing_figure), - new_func=service.create_figure, - update_func=service.update_figure, + new_func=self._service.create_figure, + update_func=self._service.update_figure, new_data={}, update_data=data, ) @@ -473,14 +469,11 @@ def add_figures( def delete_figure( self, figure_key: Union[str, int], - service: Optional["database_service.DatabaseServiceV1"] = None, ) -> str: """Add the experiment figure. Args: figure_key: Name or index of the figure. - service: Experiment service to be used to update the database. If ``None``, - the default service is used. Returns: Figure name. @@ -496,8 +489,7 @@ def delete_figure( del self._figures[figure_key] self._deleted_figures.append(figure_key) - service = service or self._service - if service and self.auto_save: + if self._service and self.auto_save: with service_exception_to_warning(): self.service.delete_figure(experiment_id=self.experiment_id, figure_name=figure_key) self._deleted_figures.remove(figure_key) @@ -544,14 +536,11 @@ def figure( def add_analysis_results( self, results: Union[DbAnalysisResult, List[DbAnalysisResult]], - service: "database_service.DatabaseServiceV1" = None, ) -> None: """Save the analysis result. Args: results: Analysis results to be saved. - service: Experiment service to be used to update the database. If ``None``, - the default service is used. """ if not isinstance(results, list): results = [results] @@ -563,20 +552,18 @@ def add_analysis_results( result.service = self.service result.auto_save = self.auto_save - use_service = service or self.service - if self.auto_save and use_service: - result.save(service=service) + if self.auto_save and self._service: + result.save(service=self._service) @auto_save def delete_analysis_result( - self, result_key: Union[int, str], service: "database_service.DatabaseServiceV1" = None + self, + result_key: Union[int, str], ) -> str: """Delete the analysis result. Args: result_key: ID or index of the analysis result to be delete. - service: Experiment service to be used to update the database. If ``None``, - the default service is used. Returns: Analysis result ID. @@ -592,8 +579,7 @@ def delete_analysis_result( del self._analysis_results[result_key] self._deleted_analysis_results.append(result_key) - service = service or self._service - if service and self.auto_save: + if self._service and self.auto_save: with service_exception_to_warning(): self.service.delete_analysis_result(result_id=result_key) self._deleted_analysis_results.remove(result_key) @@ -641,7 +627,7 @@ def analysis_results( raise TypeError(f"Invalid index type {type(index)}.") - def save(self, service: Optional["database_service.DatabaseServiceV1"] = None) -> None: + def save(self) -> None: """Save this experiment in the database. Note: @@ -652,13 +638,8 @@ def save(self, service: Optional["database_service.DatabaseServiceV1"] = None) - Note: Note that this method does not save analysis results nor figures. Use ``save_all()`` if you want to save those. - - Args: - service: Experiment service to be used to save the data. - If ``None``, the provider used to submit jobs will be used. """ - service = service or self._service - if not service: + if not self._service: LOG.warning("Experiment cannot be saved because no experiment service is available.") return @@ -682,13 +663,13 @@ def save(self, service: Optional["database_service.DatabaseServiceV1"] = None) - self._created_in_db, _ = save_data( is_new=(not self._created_in_db), - new_func=service.create_experiment, - update_func=service.update_experiment, + new_func=self._service.create_experiment, + update_func=self._service.update_experiment, new_data=new_data, update_data=update_data, ) - def save_all(self, service: Optional["database_service.DatabaseServiceV1"] = None) -> None: + def save_all(self) -> None: """Save this experiment and its analysis results and figures in the database. Note: @@ -699,18 +680,17 @@ def save_all(self, service: Optional["database_service.DatabaseServiceV1"] = Non If ``None``, the provider used to submit jobs will be used. """ # TODO - track changes - use_service = service or self._service - if not use_service: + if not self._service: LOG.warning("Experiment cannot be saved because no experiment service is available.") return - self.save(service=use_service) + self.save() for result in self._analysis_results.values(): - result.save(service) + result.save() for result in self._deleted_analysis_results.copy(): with service_exception_to_warning(): - use_service.delete_analysis_result(result_id=result) + self._service.delete_analysis_result(result_id=result) self._deleted_analysis_results.remove(result) with self._figures.lock: @@ -724,15 +704,15 @@ def save_all(self, service: Optional["database_service.DatabaseServiceV1"] = Non data = {"experiment_id": self.experiment_id, "figure": figure, "figure_name": name} save_data( is_new=True, - new_func=use_service.create_figure, - update_func=use_service.update_figure, + new_func=self._service.create_figure, + update_func=self._service.update_figure, new_data={}, update_data=data, ) for name in self._deleted_figures.copy(): with service_exception_to_warning(): - use_service.delete_figure(experiment_id=self.experiment_id, figure_name=name) + self._service.delete_figure(experiment_id=self.experiment_id, figure_name=name) self._deleted_figures.remove(name) def serialize_metadata(self) -> str: diff --git a/test/database_service/test_db_analysis_result.py b/test/database_service/test_db_analysis_result.py index e688f57aab..0cd7c77f98 100644 --- a/test/database_service/test_db_analysis_result.py +++ b/test/database_service/test_db_analysis_result.py @@ -49,7 +49,8 @@ def test_save(self): """Test saving analysis result.""" mock_service = mock.create_autospec(DatabaseServiceV1) result = self._new_analysis_result() - result.save(service=mock_service) + result.service = mock_service + result.save() mock_service.create_analysis_result.assert_called_once() def test_auto_save(self): @@ -89,15 +90,6 @@ def test_set_service_direct(self): with self.assertRaises(DbExperimentDataError): result.service = mock_service - def test_set_service_save(self): - """Test setting service when saving.""" - orig_service = mock.create_autospec(DatabaseServiceV1) - result = self._new_analysis_result(service=orig_service) - new_service = mock.create_autospec(DatabaseServiceV1) - result.save(service=new_service) - new_service.create_analysis_result.assert_called() - orig_service.create_analysis_result.assert_not_called() - def test_set_data(self): """Test setting data.""" result = self._new_analysis_result() diff --git a/test/database_service/test_db_experiment_data.py b/test/database_service/test_db_experiment_data.py index 4f77f20473..9cb23cd861 100644 --- a/test/database_service/test_db_experiment_data.py +++ b/test/database_service/test_db_experiment_data.py @@ -422,11 +422,12 @@ def test_save(self): """Test saving experiment data.""" exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") service = mock.create_autospec(DatabaseServiceV1, instance=True) - exp_data.save(service=service) + exp_data.service = service + exp_data.save() service.create_experiment.assert_called_once() _, kwargs = service.create_experiment.call_args self.assertEqual(exp_data.experiment_id, kwargs["experiment_id"]) - exp_data.save(service=service) + exp_data.save() service.update_experiment.assert_called_once() _, kwargs = service.update_experiment.call_args self.assertEqual(exp_data.experiment_id, kwargs["experiment_id"]) @@ -438,7 +439,8 @@ def test_save_all(self): exp_data.add_figures(str.encode("hello world")) analysis_result = mock.MagicMock() exp_data.add_analysis_results(analysis_result) - exp_data.save_all(service=service) + exp_data.service = service + exp_data.save_all() service.create_experiment.assert_called_once() service.create_figure.assert_called_once() analysis_result.save.assert_called_once() @@ -451,8 +453,9 @@ def test_save_all_delete(self): exp_data.add_analysis_results(mock.MagicMock()) exp_data.delete_analysis_result(0) exp_data.delete_figure(0) + exp_data.service = service - exp_data.save_all(service=service) + exp_data.save_all() service.create_experiment.assert_called_once() service.delete_figure.assert_called_once() service.delete_analysis_result.assert_called_once() @@ -484,15 +487,6 @@ def test_set_service_direct(self): with self.assertRaises(DbExperimentDataError): exp_data.service = mock_service - def test_set_service_save(self): - """Test setting service when saving.""" - orig_service = self._set_mock_service() - exp_data = DbExperimentData(backend=self.backend, experiment_type="qiskit_test") - new_service = mock.create_autospec(DatabaseServiceV1, instance=True) - exp_data.save(service=new_service) - new_service.create_experiment.assert_called() - orig_service.create_experiment.assert_not_called() - def test_new_backend_has_service(self): """Test changing backend doesn't change existing service.""" orig_service = self._set_mock_service() From c4339995de67041bce43ef998207883c4317c5c1 Mon Sep 17 00:00:00 2001 From: jessieyu Date: Thu, 1 Jul 2021 13:45:56 -0400 Subject: [PATCH 19/20] fix type hint --- .../database_service/db_analysis_result.py | 8 ++++---- .../database_service/db_experiment_data.py | 14 +++++--------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/qiskit_experiments/database_service/db_analysis_result.py b/qiskit_experiments/database_service/db_analysis_result.py index b48fd2cefb..9b08578bb4 100644 --- a/qiskit_experiments/database_service/db_analysis_result.py +++ b/qiskit_experiments/database_service/db_analysis_result.py @@ -19,7 +19,7 @@ import copy from functools import wraps -import qiskit_experiments.database_service as database_service # pylint: disable=unused-import +from .database_service import DatabaseServiceV1 from .json import NumpyEncoder, NumpyDecoder from .utils import save_data, qiskit_version from .exceptions import DbExperimentDataError @@ -77,7 +77,7 @@ def __init__( quality: Optional[str] = None, verified: bool = False, tags: Optional[List[str]] = None, - service: Optional["database_service.DatabaseServiceV1"] = None, + service: Optional[DatabaseServiceV1] = None, **kwargs, ): """AnalysisResult constructor. @@ -324,7 +324,7 @@ def experiment_id(self) -> str: return self._experiment_id @property - def service(self) -> Optional["database_service.DatabaseServiceV1"]: + def service(self) -> Optional[DatabaseServiceV1]: """Return the database service. Returns: @@ -334,7 +334,7 @@ def service(self) -> Optional["database_service.DatabaseServiceV1"]: return self._service @service.setter - def service(self, service: "database_service.DatabaseServiceV1") -> None: + def service(self, service: DatabaseServiceV1) -> None: """Set the service to be used for storing result data in a database. Args: diff --git a/qiskit_experiments/database_service/db_experiment_data.py b/qiskit_experiments/database_service/db_experiment_data.py index 03b98761fe..c27a09a443 100644 --- a/qiskit_experiments/database_service/db_experiment_data.py +++ b/qiskit_experiments/database_service/db_experiment_data.py @@ -30,7 +30,7 @@ from qiskit.providers.exceptions import JobError from qiskit.visualization import HAS_MATPLOTLIB -import qiskit_experiments.database_service as database_service # pylint: disable=unused-import +from .database_service import DatabaseServiceV1 from .exceptions import DbExperimentDataError, DbExperimentEntryNotFound, DbExperimentEntryExists from .db_analysis_result import DbAnalysisResultV1 as DbAnalysisResult from .json import NumpyEncoder, NumpyDecoder @@ -553,7 +553,7 @@ def add_analysis_results( result.auto_save = self.auto_save if self.auto_save and self._service: - result.save(service=self._service) + result.save() @auto_save def delete_analysis_result( @@ -614,7 +614,7 @@ def analysis_results( experiment_id=self.experiment_id, limit=None ) for result in retrieved_results: - self._analysis_results[result.result_id] = result + self._analysis_results[result["result_id"]] = result if index is None: return self._analysis_results.values() @@ -674,10 +674,6 @@ def save_all(self) -> None: Note: Depending on the amount of data, this operation could take a while. - - Args: - service: Experiment service to be used to save the data. - If ``None``, the provider used to submit jobs will be used. """ # TODO - track changes if not self._service: @@ -989,7 +985,7 @@ def notes(self, new_notes: str) -> None: self.save() @property - def service(self) -> Optional["database_service.DatabaseServiceV1"]: + def service(self) -> Optional[DatabaseServiceV1]: """Return the database service. Returns: @@ -998,7 +994,7 @@ def service(self) -> Optional["database_service.DatabaseServiceV1"]: return self._service @service.setter - def service(self, service: "database_service.DatabaseServiceV1") -> None: + def service(self, service: DatabaseServiceV1) -> None: """Set the service to be used for storing experiment data. Args: From 2e01e94c33cd4f244dbdd74215ffdfd985e6d395 Mon Sep 17 00:00:00 2001 From: jessieyu Date: Thu, 1 Jul 2021 17:17:47 -0400 Subject: [PATCH 20/20] review comments --- .../database_service/__init__.py | 7 ++++--- .../database_service/database_service.py | 4 ++-- .../database_service/db_analysis_result.py | 9 ++++++--- .../database_service/db_experiment_data.py | 18 ++++++++++++------ .../test_db_analysis_result.py | 2 +- .../test_db_experiment_data.py | 2 +- 6 files changed, 26 insertions(+), 16 deletions(-) diff --git a/qiskit_experiments/database_service/__init__.py b/qiskit_experiments/database_service/__init__.py index 4b6b8f7741..4ee59e18b0 100644 --- a/qiskit_experiments/database_service/__init__.py +++ b/qiskit_experiments/database_service/__init__.py @@ -23,7 +23,7 @@ service allows one to store, retrieve, and query experiment related data. :class:`DbExperimentDataV1` is the main class that defines the structure of -experiment data, which consists of the following:: +experiment data, which consists of the following: * Results from circuit execution, which is called ``data`` in this class. The :meth:`DbExperimentDataV1.add_data` @@ -60,8 +60,9 @@ class only for convenience. expected to be used when you want to interact with the database directly - for example, to retrieve a saved analysis result. -Currently only IBM Quantum provides this database service, but we plan on having -a local database service in the near future. +Currently only IBM Quantum provides this database service. See +`qiskit-ibmq-provider `_ +for more details. Classes ======= diff --git a/qiskit_experiments/database_service/database_service.py b/qiskit_experiments/database_service/database_service.py index 5d12e1a8cf..421b80a914 100644 --- a/qiskit_experiments/database_service/database_service.py +++ b/qiskit_experiments/database_service/database_service.py @@ -314,7 +314,7 @@ def create_figure( Args: experiment_id: ID of the experiment this figure is for. - figure: Name of the figure file or figure data to store. + figure: Path of the figure file or figure data to store. figure_name: Name of the figure. If ``None``, the figure file name, if given, or a generated name is used. @@ -334,7 +334,7 @@ def update_figure( Args: experiment_id: Experiment ID. - figure: Name of the figure file or figure data to store. + figure: Path of the figure file or figure data to store. figure_name: Name of the figure. Returns: diff --git a/qiskit_experiments/database_service/db_analysis_result.py b/qiskit_experiments/database_service/db_analysis_result.py index 9b08578bb4..d6b7ce5a96 100644 --- a/qiskit_experiments/database_service/db_analysis_result.py +++ b/qiskit_experiments/database_service/db_analysis_result.py @@ -42,11 +42,11 @@ def _wrapped(self, *args, **kwargs): class DbAnalysisResult: - """Base common type for all versioned AnalysisResult abstract classes. + """Base common type for all versioned DbAnalysisResult abstract classes. Note this class should not be inherited from directly, it is intended - to be used for type checking. When implementing a provider you should use - the versioned abstract classes as the parent class and not this class + to be used for type checking. When implementing a custom DbAnalysisResult + you should use the versioned classes as the parent class and not this class directly. """ @@ -369,6 +369,9 @@ def __str__(self): ret += f"\nExperiment ID: {self.experiment_id}" ret += f"\nDevice Components: {self.device_components}" ret += f"\nQuality: {self.quality}" + ret += f"\nVerified: {self.verified}" + if self.tags(): + ret += f"\nTags: {self.tags()}" ret += "\nResult Data:" for key, val in self.data().items(): ret += f"\n - {key}: {val}" diff --git a/qiskit_experiments/database_service/db_experiment_data.py b/qiskit_experiments/database_service/db_experiment_data.py index c27a09a443..ef5d1b26c2 100644 --- a/qiskit_experiments/database_service/db_experiment_data.py +++ b/qiskit_experiments/database_service/db_experiment_data.py @@ -71,8 +71,8 @@ class DbExperimentData: """Base common type for all versioned DbExperimentData classes. Note this class should not be inherited from directly, it is intended - to be used for type checking. When implementing a provider you should use - the versioned abstract classes as the parent class and not this class + to be used for type checking. When implementing a custom DbExperimentData class, + you should use the versioned classes as the parent class and not this class directly. """ @@ -381,7 +381,7 @@ def add_figures( """Add the experiment figure. Args: - figures (str or bytes or pyplot.Figure or list): Names of the figure + figures (str or bytes or pyplot.Figure or list): Paths of the figure files or figure data. figure_names (str or list): Names of the figures. If ``None``, use the figure file names, if given, or a generated name. If `figures` is a list, then @@ -571,10 +571,12 @@ def delete_analysis_result( Raises: DbExperimentEntryNotFound: If analysis result not found. """ + if isinstance(result_key, int): result_key = self._analysis_results.keys()[result_key] - elif result_key not in self._analysis_results: - raise DbExperimentEntryNotFound(f"Analysis result {result_key} not found.") + else: + # Retrieve from DB if needed. + result_key = self.analysis_results(result_key).result_id del self._analysis_results[result_key] self._deleted_analysis_results.append(result_key) @@ -958,7 +960,7 @@ def share_level(self, new_level: str) -> None: Args: new_level: New experiment share level. Valid share levels are provider- specified. For example, IBM Quantum experiment service allows - "global", "hub", "group", "project", and "private". + "public", "hub", "group", "project", and "private". """ self._share_level = new_level if self.auto_save: @@ -1025,6 +1027,10 @@ def __str__(self): if status == "ERROR": ret += "\n " ret += "\n ".join(self._errors) + if self.backend: + ret += f"\nBackend: {self.backend}" + if self.tags(): + ret += f"\nTags: {self.tags()}" ret += f"\nData: {len(self._data)}" ret += f"\nAnalysis Results: {n_res}" ret += f"\nFigures: {len(self._figures)}" diff --git a/test/database_service/test_db_analysis_result.py b/test/database_service/test_db_analysis_result.py index 0cd7c77f98..c0b7829816 100644 --- a/test/database_service/test_db_analysis_result.py +++ b/test/database_service/test_db_analysis_result.py @@ -137,7 +137,7 @@ def _new_analysis_result(self, **kwargs): values = { "result_data": {"foo": "bar"}, "result_type": "some_type", - "device_components": ["Q1", "Q1"], + "device_components": ["Q1", "Q2"], "experiment_id": "1234", } values.update(kwargs) diff --git a/test/database_service/test_db_experiment_data.py b/test/database_service/test_db_experiment_data.py index 9cb23cd861..16d4269337 100644 --- a/test/database_service/test_db_experiment_data.py +++ b/test/database_service/test_db_experiment_data.py @@ -52,7 +52,7 @@ def test_stored_data_attributes(self): """Test stored data attributes.""" attrs = { "job_ids": ["job1"], - "share_level": "global", + "share_level": "public", "figure_names": ["figure1"], "notes": "some notes", }