diff --git a/qiskit_experiments/curve_analysis/base_curve_analysis.py b/qiskit_experiments/curve_analysis/base_curve_analysis.py index cd9c0128d7..7327b034e9 100644 --- a/qiskit_experiments/curve_analysis/base_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/base_curve_analysis.py @@ -14,6 +14,7 @@ Base class of curve analysis. """ +from collections import OrderedDict import warnings from abc import ABC, abstractmethod from typing import Dict, List, Union @@ -29,6 +30,7 @@ BaseAnalysis, ExperimentData, Options, + AnalysisConfig, ) from qiskit_experiments.visualization import ( BaseDrawer, @@ -422,3 +424,38 @@ def _initialize( DeprecationWarning, ) self.set_options(data_subfit_map=data_subfit_map) + + def config(self) -> AnalysisConfig: + """Return the config dataclass for this analysis. We replicate the method from `BaseAnalysis` + because we cannot directly modify the returned object. This limitation arises from our use of + it hashable we use `dataclasses.dataclass(frozen=True)` to ensure its hashability. + Additionally, we include the `plotter` and `drawer` options, which are specific to + this context.""" + args = tuple(getattr(self, "__init_args__", OrderedDict()).values()) + kwargs = dict(getattr(self, "__init_kwargs__", OrderedDict())) + # Only store non-default valued options + options = dict((key, getattr(self._options, key)) for key in self._set_options) + if isinstance(self.plotter, BasePlotter): + plotter = self.plotter.config() + else: + plotter = None + + return AnalysisConfig( + cls=type(self), + args=args, + kwargs=kwargs, + options=options, + plotter=plotter, + ) + + @classmethod + def from_config(cls, config: Union[AnalysisConfig, Dict]) -> "BaseCurveAnalysis": + """Initialize a curve analysis class from analysis config""" + if isinstance(config, dict): + config = AnalysisConfig(**config) + ret = super().from_config(config) + if config.plotter: + ret.options.plotter = config.plotter.plotter() + else: + ret.options.plotter = None + return ret diff --git a/qiskit_experiments/framework/base_experiment.py b/qiskit_experiments/framework/base_experiment.py index 41240df41c..cab7ee47f0 100644 --- a/qiskit_experiments/framework/base_experiment.py +++ b/qiskit_experiments/framework/base_experiment.py @@ -12,23 +12,30 @@ """ Base Experiment class. """ - +import warnings from abc import ABC, abstractmethod import copy from collections import OrderedDict +import logging +import traceback from typing import Sequence, Optional, Tuple, List, Dict, Union from qiskit import transpile, QuantumCircuit -from qiskit.providers import Job, Backend +from qiskit.providers import Provider, Job, Backend from qiskit.exceptions import QiskitError from qiskit.qobj.utils import MeasLevel from qiskit.providers.options import Options +from qiskit_ibm_experiment import IBMExperimentService from qiskit_experiments.framework import BackendData from qiskit_experiments.framework.store_init_args import StoreInitArgs from qiskit_experiments.framework.base_analysis import BaseAnalysis from qiskit_experiments.framework.experiment_data import ExperimentData from qiskit_experiments.framework.configs import ExperimentConfig +from qiskit_experiments.framework.json import ExperimentDecoder from qiskit_experiments.database_service import Qubit +from qiskit_experiments.database_service.utils import zip_to_objs + +LOG = logging.getLogger(__name__) class BaseExperiment(ABC, StoreInitArgs): @@ -171,6 +178,12 @@ def config(self) -> ExperimentConfig: (key, getattr(self._transpile_options, key)) for key in self._set_transpile_options ) run_options = dict((key, getattr(self._run_options, key)) for key in self._set_run_options) + + if isinstance(self.analysis, BaseAnalysis): + analysis_config = self.analysis.config() + else: + analysis_config = None + return ExperimentConfig( cls=type(self), args=args, @@ -178,6 +191,7 @@ def config(self) -> ExperimentConfig: experiment_options=experiment_options, transpile_options=transpile_options, run_options=run_options, + analysis=analysis_config, ) @classmethod @@ -192,8 +206,79 @@ def from_config(cls, config: Union[ExperimentConfig, Dict]) -> "BaseExperiment": ret.set_transpile_options(**config.transpile_options) if config.run_options: ret.set_run_options(**config.run_options) + if config.analysis: + ret.analysis = config.analysis.analysis() + else: + ret.analysis = None return ret + @classmethod + def load( + cls, + experiment_id: str, + service: Optional[IBMExperimentService] = None, + provider: Optional[Provider] = None, + ) -> "BaseExperiment": + """Load a saved experiment from a database service. + + Args: + experiment_id: Experiment ID. + service: the database service. + provider: an IBMProvider required for loading the experiment data and + can be used to initialize the service. When using + :external+qiskit_ibm_runtime:doc:`qiskit-ibm-runtime `, + this is the :class:`~qiskit_ibm_runtime.QiskitRuntimeService` and should + not be confused with the experiment database service + :meth:`qiskit_ibm_experiment.IBMExperimentService`. + + Returns: + The reconstructed experiment. + Raises: + QiskitError: If not service nor provider were given. + """ + if service is None: + if provider is None: + raise QiskitError( + "Loading an experiment requires a valid Qiskit provider or experiment service." + ) + service = ExperimentData.get_service_from_provider(provider) + + data = service.experiment(experiment_id, json_decoder=ExperimentDecoder) + # loading metadata artifact if exist + if service.experiment_has_file(experiment_id, ExperimentData._metadata_filename): + metadata = service.file_download( + experiment_id, ExperimentData._metadata_filename, json_decoder=ExperimentDecoder + ) + data.metadata.update(metadata) + + # Recreate artifacts + experiment_config_filename = "experiment_config" + try: + if experiment_config_filename in data.metadata: + if service.experiment_has_file(experiment_id, experiment_config_filename): + artifact_file = service.file_download(experiment_id, experiment_config_filename) + + experiment_config_artifact = zip_to_objs(artifact_file, json_decoder=ExperimentDecoder)[0] + backend_name = data.backend + reconstructed_experiment = cls.from_config(experiment_config_artifact.data) + if backend_name: + reconstructed_experiment.backend = provider.get_backend(backend_name) + else: + warnings.warn("No backend for loaded data.") + return reconstructed_experiment + else: + raise QiskitError( + "The experiment doesn't have saved experiment in the DB to load." + ) + else: + raise QiskitError( + f"No '{experiment_config_filename}' field in the experiment metadata. can't load " + f"the experiment." + ) + + except Exception: # pylint: disable=broad-except: + LOG.error("Unable to load artifacts: %s", traceback.format_exc()) + def run( self, backend: Optional[Backend] = None, @@ -475,6 +560,7 @@ def _metadata(self) -> Dict[str, any]: By default, this assumes the experiment is running on qubits only. Subclasses can override this method to add custom experiment metadata to the returned experiment result data. """ + metadata = { "physical_qubits": list(self.physical_qubits), "device_components": list(map(Qubit, self.physical_qubits)), diff --git a/qiskit_experiments/framework/configs.py b/qiskit_experiments/framework/configs.py index 9e114584e3..c55a336758 100644 --- a/qiskit_experiments/framework/configs.py +++ b/qiskit_experiments/framework/configs.py @@ -21,46 +21,90 @@ @dataclasses.dataclass(frozen=True) -class ExperimentConfig: - """Store configuration settings for an Experiment class. +class DrawerConfig: + """Store configuration settings for a Drawer class. - This stores the current configuration of a :class:`.BaseExperiment` and can be used to - reconstruct the experiment using either the :meth:`experiment` property if the experiment class - type is currently stored, or the :meth:`~.BaseExperiment.from_config` class method of the - appropriate experiment. + This stores the current configuration of a :class:`.BaseDrawer` and can be used to reconstruct + the drawer class using either the :meth:`drawer` property if the drawer class type is + currently stored, or the :meth:`~.BaseDrawer.from_config` class method. """ cls: type = None - args: Tuple[Any] = dataclasses.field(default_factory=tuple) - kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) - experiment_options: Dict[str, Any] = dataclasses.field(default_factory=dict) - transpile_options: Dict[str, Any] = dataclasses.field(default_factory=dict) - run_options: Dict[str, Any] = dataclasses.field(default_factory=dict) + options: Dict[str, Any] = dataclasses.field(default_factory=dict) + figure_options: Dict[str, Any] = dataclasses.field(default_factory=dict) version: str = __version__ - def experiment(self): - """Return the experiment constructed from this config. + def drawer(self): + """Return the drawer class constructed from this config. Returns: - BaseExperiment: The experiment reconstructed from the config. + BaseDrawer: The drawer reconstructed from the config. Raises: - QiskitError: If the experiment class is not stored, + QiskitError: If the drawer class is not stored, was not successful deserialized, or reconstruction - of the experiment fails. + of the drawer class fails. """ cls = self.cls if cls is None: - raise QiskitError("No experiment class in experiment config") + raise QiskitError("No drawer class in drawer config") if isinstance(cls, dict): raise QiskitError( - "Unable to load experiment class. Try manually loading " - "experiment using `Experiment.from_config(config)` instead." + "Unable to load drawer class. Try manually loading " + "drawer using `Analysis.plotter.drawer.from_config(config)` instead." ) try: return cls.from_config(self) except Exception as ex: - msg = "Unable to construct experiments from config." + msg = "Unable to construct drawer from config." + if cls.version != __version__: + msg += ( + f" Note that config version ({cls.version}) differs from the current" + f" qiskit-experiments version ({__version__}). You could try" + " installing a compatible qiskit-experiments version." + ) + raise QiskitError(f"{msg}\nError Message:\n{str(ex)}") from ex + + +@dataclasses.dataclass(frozen=True) +class PlotterConfig: + """Store configuration settings for a Drawer class. + + This stores the current configuration of a :class:`.BasePlotter` and can be used to reconstruct + the plotter class using either the :meth:`plotter` property if the plotter class type is + currently stored, or the :meth:`~.BasePlotter.from_config` class method. + """ + + cls: type = None + options: Dict[str, Any] = dataclasses.field(default_factory=dict) + figure_options: Dict[str, Any] = dataclasses.field(default_factory=dict) + # drawer: Dict[str, Any] = dataclasses.field(default_factory=dict) + drawer: DrawerConfig = None + version: str = __version__ + + def plotter(self): + """Return the plotter of the analysis class constructed from this config. + + Returns: + BasePlotter: The plotter reconstructed from the config. + + Raises: + QiskitError: If the plotter class is not stored, + was not successful deserialized, or reconstruction + of the plotter class fails. + """ + cls = self.cls + if cls is None: + raise QiskitError("No plotter class in plotter config") + if isinstance(cls, dict): + raise QiskitError( + "Unable to load plotter class. Try manually loading " + "analysis using `Analysis.plotter.from_config(config)` instead." + ) + try: + return cls.from_config(self) + except Exception as ex: + msg = "Unable to construct plotter from config." if cls.version != __version__: msg += ( f" Note that config version ({cls.version}) differs from the current" @@ -84,6 +128,7 @@ class AnalysisConfig: args: Tuple[Any] = dataclasses.field(default_factory=tuple) kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) options: Dict[str, Any] = dataclasses.field(default_factory=dict) + plotter: PlotterConfig = None version: str = __version__ def analysis(self): @@ -116,3 +161,54 @@ def analysis(self): " installing a compatible qiskit-experiments version." ) raise QiskitError(f"{msg}\nError Message:\n{str(ex)}") from ex + + +@dataclasses.dataclass(frozen=True) +class ExperimentConfig: + """Store configuration settings for an Experiment class. + + This stores the current configuration of a :class:`.BaseExperiment` and can be used to + reconstruct the experiment using either the :meth:`experiment` property if the experiment class + type is currently stored, or the :meth:`~.BaseExperiment.from_config` class method of the + appropriate experiment. + """ + + cls: type = None + args: Tuple[Any] = dataclasses.field(default_factory=tuple) + kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) + experiment_options: Dict[str, Any] = dataclasses.field(default_factory=dict) + transpile_options: Dict[str, Any] = dataclasses.field(default_factory=dict) + run_options: Dict[str, Any] = dataclasses.field(default_factory=dict) + analysis: AnalysisConfig = None + version: str = __version__ + + def experiment(self): + """Return the experiment constructed from this config. + + Returns: + BaseExperiment: The experiment reconstructed from the config. + + Raises: + QiskitError: If the experiment class is not stored, + was not successful deserialized, or reconstruction + of the experiment fails. + """ + cls = self.cls + if cls is None: + raise QiskitError("No experiment class in experiment config") + if isinstance(cls, dict): + raise QiskitError( + "Unable to load experiment class. Try manually loading " + "experiment using `Experiment.from_config(config)` instead." + ) + try: + return cls.from_config(self) + except Exception as ex: + msg = "Unable to construct experiments from config." + if cls.version != __version__: + msg += ( + f" Note that config version ({cls.version}) differs from the current" + f" qiskit-experiments version ({__version__}). You could try" + " installing a compatible qiskit-experiments version." + ) + raise QiskitError(f"{msg}\nError Message:\n{str(ex)}") from ex diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index d9fb1af075..ed0e6d0f9a 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -19,6 +19,8 @@ from qiskit_experiments.framework import Options +from qiskit_experiments.framework.configs import DrawerConfig + from ..style import PlotStyle from ..utils import ExtentTuple @@ -552,27 +554,34 @@ def image( def figure(self): """Return figure object handler to be saved in the database.""" - def config(self) -> Dict: + def config(self) -> DrawerConfig: """Return the config dictionary for this drawer.""" options = dict((key, getattr(self._options, key)) for key in self._set_options) figure_options = dict( (key, getattr(self._figure_options, key)) for key in self._set_figure_options ) + return DrawerConfig( + cls=type(self), + options=options, + figure_options=figure_options, + ) - return { - "cls": type(self), - "options": options, - "figure_options": figure_options, - } + @classmethod + def from_config(cls, config: Union[DrawerConfig, Dict]) -> "BaseDrawer": + """Initialize a drawer class from analysis config""" + if isinstance(config, dict): + config = DrawerConfig(**config) + # Create Drawer instance + ret = cls() + if config.options: + ret.set_options(**config.options) + if config.figure_options: + ret.set_figure_options(**config.figure_options) + return ret def __json_encode__(self): return self.config() @classmethod def __json_decode__(cls, value): - instance = cls() - if "options" in value: - instance.set_options(**value["options"]) - if "figure_options" in value: - instance.set_figure_options(**value["figure_options"]) - return instance + return cls.from_config(value) diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py index 2810184b8d..223327dbc7 100644 --- a/qiskit_experiments/visualization/plotters/base_plotter.py +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -16,6 +16,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union from qiskit_experiments.framework import Options +from qiskit_experiments.framework.configs import PlotterConfig from qiskit_experiments.visualization.drawers import BaseDrawer, SeriesName from ..style import PlotStyle @@ -553,36 +554,39 @@ def _configure_drawer(self): # Use drawer.set_figure_options so figure options are serialized. self.drawer.set_figure_options(**_drawer_figure_options) - def config(self) -> Dict: + def config(self) -> PlotterConfig: """Return the config dictionary for this drawing.""" options = dict((key, getattr(self._options, key)) for key in self._set_options) figure_options = dict( (key, getattr(self._figure_options, key)) for key in self._set_figure_options ) - drawer = self.drawer.__json_encode__() + drawer = self.drawer.config() + return PlotterConfig( + cls=type(self), + options=options, + figure_options=figure_options, + drawer=drawer, + ) + + @classmethod + def from_config(cls, config: Union[PlotterConfig, Dict]) -> "BasePlotter": + """Initialize a plotter class from analysis config""" + if isinstance(config, dict): + config = PlotterConfig(**config) - return { - "cls": type(self), - "options": options, - "figure_options": figure_options, - "drawer": drawer, - } + # Create plotter instance + ret = cls(config.drawer.drawer()) + # if "options" in config: + if config.options: + ret.set_options(**config.options) + # if "figure_options" in config: + if config.figure_options: + ret.set_figure_options(**config.figure_options) + return ret def __json_encode__(self): return self.config() @classmethod def __json_decode__(cls, value): - ## Process drawer as it's needed to create a plotter - drawer_values = value["drawer"] - # We expect a subclass of BaseDrawer - drawer_cls: BaseDrawer = drawer_values["cls"] - drawer = drawer_cls.__json_decode__(drawer_values) - - # Create plotter instance - instance = cls(drawer) - if "options" in value: - instance.set_options(**value["options"]) - if "figure_options" in value: - instance.set_figure_options(**value["figure_options"]) - return instance + return cls.from_config(value) diff --git a/test/framework/test_framework.py b/test/framework/test_framework.py index 1a2ae395d3..fff9b946dd 100644 --- a/test/framework/test_framework.py +++ b/test/framework/test_framework.py @@ -17,6 +17,7 @@ from test.fake_experiment import FakeExperiment, FakeAnalysis from test.base import QiskitExperimentsTestCase +import json import ddt from qiskit import QuantumCircuit @@ -33,6 +34,8 @@ BaseAnalysis, AnalysisResultData, AnalysisStatus, + ExperimentEncoder, + ExperimentDecoder, ) from qiskit_experiments.test.fake_backend import FakeBackend from qiskit_experiments.test.utils import FakeJob @@ -426,3 +429,16 @@ def circuits(self): self.assertEqual(exp2.experiment_type, "MyExp") exp2.experiment_type = "suieee" self.assertEqual(exp2.experiment_type, "suieee") + + def test_experiment_serialization_and_deserialization(self): + """Check if the serialization of the reconstructed analysis and the original + analysis are the same.""" + exp = FakeExperiment((0, 2)) + exp.analysis.set_options(dummyoption="dummy") + exp_config = exp.config() + encode = json.dumps(exp_config, cls=ExperimentEncoder) + decode = json.loads(encode, cls=ExperimentDecoder) + + reconstructed_exp_from_config = decode.experiment() + # checking the config method output as there is no __equal__ op for plotter and figure. + self.assertEqual(exp.analysis.config(), reconstructed_exp_from_config.analysis.config())