diff --git a/qiskit_experiments/analysis/plotting.py b/qiskit_experiments/analysis/plotting.py index bd9312e17a..1c13b8ff04 100644 --- a/qiskit_experiments/analysis/plotting.py +++ b/qiskit_experiments/analysis/plotting.py @@ -73,8 +73,8 @@ def plot_curve_fit( ImportError: if matplotlib is not installed. """ if ax is None: - plt.figure() - ax = plt.gca() + figure = plt.figure() + ax = figure.subplots() # Result data popt = result["popt"] @@ -132,8 +132,8 @@ def plot_scatter( AxesSubPlot: the matplotlib axes containing the plot. """ if ax is None: - plt.figure() - ax = plt.gca() + figure = plt.figure() + ax = figure.subplots() # Default plot options plot_opts = kwargs.copy() @@ -178,8 +178,8 @@ def plot_errorbar( AxesSubPlot: the matplotlib axes containing the plot. """ if ax is None: - plt.figure() - ax = plt.gca() + figure = plt.figure() + ax = figure.subplots() # Default plot options plot_opts = kwargs.copy() diff --git a/qiskit_experiments/base_analysis.py b/qiskit_experiments/base_analysis.py index c9aee9dea1..cb4e36a6a3 100644 --- a/qiskit_experiments/base_analysis.py +++ b/qiskit_experiments/base_analysis.py @@ -14,6 +14,7 @@ """ from abc import ABC, abstractmethod +from typing import List, Tuple from qiskit.exceptions import QiskitError @@ -26,16 +27,22 @@ class BaseAnalysis(ABC): # Expected experiment data container for analysis __experiment_data__ = ExperimentData - def run(self, experiment_data, save=True, return_figures=False, **options): + def run( + self, + experiment_data: ExperimentData, + save: bool = True, + return_figures: bool = False, + **options, + ): """Run analysis and update stored ExperimentData with analysis result. Args: - experiment_data (ExperimentData): the experiment data to analyze. - save (bool): if True save analysis results and figures to the - :class:`ExperimentData`. - return_figures (bool): if true return a pair of - ``(analysis_results, figures)``, - otherwise return only analysis_results. + experiment_data: the experiment data to analyze. + save: if True save analysis results and figures to the + :class:`ExperimentData`. + return_figures: if true return a pair of + ``(analysis_results, figures)``, + otherwise return only analysis_results. options: kwarg options for analysis function. Returns: @@ -56,10 +63,6 @@ def run(self, experiment_data, save=True, return_figures=False, **options): f"Invalid experiment data type, expected {self.__experiment_data__.__name__}" f" but received {type(experiment_data).__name__}" ) - - # Wait for experiment job to finish - # experiment_data.block_for_result() - # Run analysis # pylint: disable=broad-except try: @@ -84,17 +87,19 @@ def run(self, experiment_data, save=True, return_figures=False, **options): return analysis_results @abstractmethod - def _run_analysis(self, experiment_data, **options): + def _run_analysis( + self, data: ExperimentData, **options + ) -> Tuple[List[AnalysisResult], List["Figure"]]: """Run analysis on circuit data. Args: - experiment_data (ExperimentData): the experiment data to analyze. + experiment_data: the experiment data to analyze. options: kwarg options for analysis function. Returns: tuple: A pair ``(analysis_results, figures)`` where ``analysis_results`` may be a single or list of - AnalysisResult objects, and ``figures`` may be - None, a single figure, or a list of figures. + AnalysisResult objects, and ``figures`` is a list of any + figures for the experiment. """ pass diff --git a/qiskit_experiments/base_experiment.py b/qiskit_experiments/base_experiment.py index 8d0105110e..44e55a95ca 100644 --- a/qiskit_experiments/base_experiment.py +++ b/qiskit_experiments/base_experiment.py @@ -14,10 +14,13 @@ """ from abc import ABC, abstractmethod +from typing import Union, Iterable, Optional, Tuple, List from numbers import Integral from qiskit import transpile, assemble from qiskit.exceptions import QiskitError +from qiskit.providers.backend import Backend +from qiskit.providers.basebackend import BaseBackend as LegacyBackend from .experiment_data import ExperimentData @@ -64,15 +67,20 @@ class BaseExperiment(ABC): # Custom default run (assemble) options for experiment subclasses __run_defaults__ = {} - def __init__(self, qubits, experiment_type=None, circuit_options=None): + def __init__( + self, + qubits: Union[int, Iterable[int]], + experiment_type: Optional[str] = None, + circuit_options: Optional[Iterable[str]] = None, + ): """Initialize the experiment object. Args: - qubits (int or Iterable[int]): the number of qubits or list of - physical qubits for the experiment. - experiment_type (str): Optional, the experiment type string. - circuit_options (Iterable): Optional, list of kwarg names for - the subclassed `circuit` method. + qubits: the number of qubits or list of physical qubits + for the experiment. + experiment_type: Optional, the experiment type string. + circuit_options: Optional, list of kwarg names for + the subclassed `circuit` method. Raises: QiskitError: if qubits is a list and contains duplicates. @@ -94,16 +102,21 @@ def __init__(self, qubits, experiment_type=None, circuit_options=None): # Store options and values self._circuit_options = set(circuit_options) if circuit_options else set() - def run(self, backend, experiment_data=None, **kwargs): + def run( + self, + backend: "Backend", + analysis: bool = True, + experiment_data: Optional[ExperimentData] = None, + **kwargs, + ) -> ExperimentData: """Run an experiment and perform analysis. Args: - backend (Backend): The backend to run the experiment on. - experiment_data (ExperimentData): Optional, add results to existing - experiment data. If None a new ExperimentData object will be - returned. - kwargs: keyword arguments for self.circuit, - qiskit.transpile, and backend.run. + backend: The backend to run the experiment on. + analysis: If True run analysis on experiment data. + experiment_data: Optional, add results to existing experiment data. + If None a new ExperimentData object will be returned. + kwargs: keyword arguments for self.circuit, qiskit.transpile, and backend.run. Returns: ExperimentData: the experiment data object. @@ -112,7 +125,7 @@ def run(self, backend, experiment_data=None, **kwargs): # Create new experiment data if experiment_data is None: - experiment_data = self.__experiment_data__(self) + experiment_data = self.__experiment_data__(self, backend=backend) # Filter kwargs run_options = self.__run_defaults__.copy() @@ -125,14 +138,17 @@ def run(self, backend, experiment_data=None, **kwargs): # Generate and run circuits circuits = self.transpiled_circuits(backend, **circuit_options) - qobj = assemble(circuits, backend, **run_options) - job = backend.run(qobj) + if isinstance(backend, LegacyBackend): + qobj = assemble(circuits, backend=backend, **run_options) + job = backend.run(qobj) + else: + job = backend.run(circuits, **run_options) # Add Job to ExperimentData experiment_data.add_data(job) # Queue analysis of data for when job is finished - if self.__analysis_class__ is not None: + if analysis and self.__analysis_class__ is not None: # pylint: disable = not-callable self.__analysis_class__().run(experiment_data, **kwargs) @@ -140,17 +156,17 @@ def run(self, backend, experiment_data=None, **kwargs): return experiment_data @property - def num_qubits(self): + def num_qubits(self) -> int: """Return the number of qubits for this experiment.""" return self._num_qubits @property - def physical_qubits(self): + def physical_qubits(self) -> Tuple[int]: """Return the physical qubits for this experiment.""" return self._physical_qubits @classmethod - def analysis(cls, **kwargs): + def analysis(cls, **kwargs) -> "BaseAnalysis": """Return the default Analysis class for the experiment.""" if cls.__analysis_class__ is None: raise QiskitError( @@ -160,15 +176,17 @@ def analysis(cls, **kwargs): return cls.__analysis_class__(**kwargs) @abstractmethod - def circuits(self, backend=None, **circuit_options): + def circuits( + self, backend: Optional[Backend] = None, **circuit_options + ) -> List["QuantumCircuit"]: """Return a list of experiment circuits. Args: - backend (Backend): Optional, a backend object. + backend: Optional, a backend object. circuit_options: kwarg options for the function. Returns: - List[QuantumCircuit]: A list of :class:`QuantumCircuit`s. + A list of :class:`QuantumCircuit`s. .. note: These circuits should be on qubits ``[0, .., N-1]`` for an @@ -180,18 +198,20 @@ def circuits(self, backend=None, **circuit_options): # This allows these options to have default values, and be # documented in the methods docstring for the API docs. - def transpiled_circuits(self, backend=None, **kwargs): + def transpiled_circuits( + self, backend: Optional[Backend] = None, **kwargs + ) -> List["QuantumCircuit"]: """Return a list of experiment circuits. Args: - backend (Backend): Optional, a backend object to use as the - argument for the :func:`qiskit.transpile` - function. + backend: Optional, a backend object to use as the + argument for the :func:`qiskit.transpile` + function. kwargs: kwarg options for the :meth:`circuits` method, and :func:`qiskit.transpile` function. Returns: - List[QuantumCircuit]: A list of :class:`QuantumCircuit`s. + A list of :class:`QuantumCircuit`s. Raises: QiskitError: if an initial layout is specified in the diff --git a/qiskit_experiments/characterization/t1_experiment.py b/qiskit_experiments/characterization/t1_experiment.py index e1b23dc74d..33aec872b5 100644 --- a/qiskit_experiments/characterization/t1_experiment.py +++ b/qiskit_experiments/characterization/t1_experiment.py @@ -23,6 +23,7 @@ from qiskit_experiments.base_analysis import BaseAnalysis from qiskit_experiments.analysis.curve_fitting import process_curve_data, curve_fit from qiskit_experiments.analysis.data_processing import level2_probability +from qiskit_experiments.analysis.plotting import plot_curve_fit, plot_errorbar, HAS_MATPLOTLIB from qiskit_experiments import AnalysisResult @@ -39,8 +40,10 @@ def _run_analysis( t1_bounds=None, amplitude_bounds=None, offset_bounds=None, + plot=True, + ax=None, **kwargs, - ) -> Tuple[AnalysisResult, None]: + ) -> Tuple[AnalysisResult, List["figure"]]: """ Calculate T1 @@ -52,20 +55,21 @@ def _run_analysis( t1_bounds (list of two floats): Optional, lower bound and upper bound to T1 amplitude_bounds (list of two floats): Optional, lower bound and upper bound to the amplitude offset_bounds (list of two floats): Optional, lower bound and upper bound to the offset + plot: If True generate a plot of fitted data. + ax: Optional, matplotlib axis to add plot to. kwargs: Trailing unused function parameters Returns: The analysis result with the estimated T1 """ - - unit = experiment_data._data[0]["metadata"]["unit"] - conversion_factor = experiment_data._data[0]["metadata"].get("dt_factor", None) + data = experiment_data.data() + unit = data[0]["metadata"]["unit"] + conversion_factor = data[0]["metadata"].get("dt_factor", None) + qubit = data[0]["metadata"]["qubit"] if conversion_factor is None: conversion_factor = 1 if unit == "s" else apply_prefix(1, unit) - xdata, ydata, sigma = process_curve_data( - experiment_data._data, lambda datum: level2_probability(datum, "1") - ) + xdata, ydata, sigma = process_curve_data(data, lambda datum: level2_probability(datum, "1")) xdata *= conversion_factor if t1_guess is None: @@ -83,17 +87,13 @@ def _run_analysis( if offset_bounds is None: offset_bounds = [0, 1] - fit_result = curve_fit( - lambda x, a, tau, c: a * np.exp(-x / tau) + c, - xdata, - ydata, - [amplitude_guess, t1_guess, offset_guess], - sigma, - tuple( - [amp_bnd, t1_bnd, offset_bnd] - for amp_bnd, t1_bnd, offset_bnd in zip(amplitude_bounds, t1_bounds, offset_bounds) - ), - ) + # Perform fit + def fit_fun(x, a, tau, c): + return a * np.exp(-x / tau) + c + + init = {"a": amplitude_guess, "tau": t1_guess, "c": offset_guess} + bounds = {"a": amplitude_bounds, "tau": t1_bounds, "c": offset_bounds} + fit_result = curve_fit(fit_fun, xdata, ydata, init, sigma=sigma, bounds=bounds) analysis_result = AnalysisResult( { @@ -112,7 +112,16 @@ def _run_analysis( if unit == "dt": analysis_result["fit"]["dt"] = conversion_factor - return analysis_result, None + # Generate fit plot + if plot and HAS_MATPLOTLIB: + ax = plot_curve_fit(fit_fun, fit_result, ax=ax) + ax = plot_errorbar(xdata, ydata, sigma, ax=ax) + self._format_plot(ax, fit_result, qubit=qubit) + figures = [ax.get_figure()] + else: + figures = None + + return analysis_result, figures @staticmethod def _fit_quality(fit_out, fit_err, reduced_chisq): @@ -129,6 +138,47 @@ def _fit_quality(fit_out, fit_err, reduced_chisq): else: return "computer_bad" + @classmethod + def _format_plot(cls, ax, analysis_result, qubit=None, add_label=True): + """Format curve fit plot""" + # Formatting + ax.tick_params(labelsize=14) + if qubit is not None: + ax.set_title(f"Qubit {qubit}", fontsize=16) + ax.set_xlabel("Delay (s)", fontsize=16) + ax.set_ylabel("P(1)", fontsize=16) + ax.grid(True) + + if add_label: + t1 = analysis_result["popt"][1] + t1_err = analysis_result["popt_err"][1] + # Convert T1 to time unit for pretty printing + if t1 < 1e-7: + scale = 1e9 + unit = "ns" + elif t1 < 1e-4: + scale = 1e6 + unit = "μs" + elif t1 < 0.1: + scale = 1e3 + unit = "ms" + else: + scale = 1 + unit = "s" + box_text = "$T_1$ = {:.2f} \u00B1 {:.2f} {}".format(t1 * scale, t1_err * scale, unit) + bbox_props = dict(boxstyle="square,pad=0.3", fc="white", ec="black", lw=1) + ax.text( + 0.6, + 0.9, + box_text, + ha="center", + va="center", + size=14, + bbox=bbox_props, + transform=ax.transAxes, + ) + return ax + class T1Experiment(BaseExperiment): """T1 experiment class""" diff --git a/qiskit_experiments/composite/composite_analysis.py b/qiskit_experiments/composite/composite_analysis.py index 625215e976..265c3d9c03 100644 --- a/qiskit_experiments/composite/composite_analysis.py +++ b/qiskit_experiments/composite/composite_analysis.py @@ -13,7 +13,6 @@ Composite Experiment Analysis class. """ -from qiskit.exceptions import QiskitError from qiskit_experiments.base_analysis import BaseAnalysis, AnalysisResult from .composite_experiment_data import CompositeExperimentData @@ -23,12 +22,12 @@ class CompositeAnalysis(BaseAnalysis): __experiment_data__ = CompositeExperimentData - def _run_analysis(self, experiment_data, **options): + # pylint: disable = arguments-differ + def _run_analysis(self, experiment_data: CompositeExperimentData, **options): """Run analysis on circuit data. Args: - experiment_data (CompositeExperimentData): the experiment data - to analyze. + experiment_data: the experiment data to analyze. options: kwarg options for analysis function. Returns: @@ -41,25 +40,26 @@ def _run_analysis(self, experiment_data, **options): QiskitError: if analysis is attempted on non-composite experiment data. """ - if not isinstance(experiment_data, CompositeExperimentData): - raise QiskitError("CompositeAnalysis must be run on CompositeExperimentData.") - - # Run analysis for sub-experiments - for expr, expr_data in zip( - experiment_data._experiment._experiments, experiment_data._composite_expdata - ): - expr.analysis().run(expr_data, **options) - - # Add sub-experiment metadata as result of batch experiment + # Run analysis for sub-experiments and add sub-experiment metadata + # as result of batch experiment # Note: if Analysis results had ID's these should be included here # rather than just the sub-experiment IDs sub_types = [] sub_ids = [] sub_qubits = [] - for expr in experiment_data._composite_expdata: - sub_types.append(expr._experiment._type) - sub_ids.append(expr.experiment_id) - sub_qubits.append(expr.experiment().physical_qubits) + + comp_exp = experiment_data.experiment + for i in range(comp_exp.num_experiments): + # Run analysis for sub-experiments and add sub-experiment metadata + expdata = experiment_data.component_experiment_data(i) + comp_exp.component_analysis(i).run(expdata, **options) + + # Add sub-experiment metadata as result of batch experiment + # Note: if Analysis results had ID's these should be included here + # rather than just the sub-experiment IDs + sub_types.append(expdata.experiment_type) + sub_ids.append(expdata.experiment_id) + sub_qubits.append(expdata.experiment.physical_qubits) analysis_result = AnalysisResult( { diff --git a/qiskit_experiments/composite/composite_experiment_data.py b/qiskit_experiments/composite/composite_experiment_data.py index 48dee73d8c..efe69a9fb4 100644 --- a/qiskit_experiments/composite/composite_experiment_data.py +++ b/qiskit_experiments/composite/composite_experiment_data.py @@ -13,35 +13,51 @@ Composite Experiment data class. """ +from typing import Optional, Union, List from qiskit.result import marginal_counts +from qiskit.exceptions import QiskitError from qiskit_experiments.experiment_data import ExperimentData class CompositeExperimentData(ExperimentData): """Composite experiment data class""" - def __init__(self, experiment): - """Initialize the experiment data. + def __init__( + self, + experiment: "CompositeExperiment", + backend: Optional[Union["Backend", "BaseBackend"]] = None, + job_ids: Optional[List[str]] = None, + ): + """Initialize experiment data. Args: - experiment (CompositeExperiment): experiment object that - generated the data. + experiment: experiment object that generated the data. + backend: Backend the experiment runs on. It can either be a + :class:`~qiskit.providers.Backend` instance or just backend name. + job_ids: IDs of jobs submitted for the experiment. + + Raises: + ExperimentError: If an input argument is invalid. """ - super().__init__(experiment) + + super().__init__( + experiment, + backend=backend, + job_ids=job_ids, + ) # Initialize sub experiments - self._composite_expdata = [ - expr.__experiment_data__(expr) for expr in self._experiment._experiments - ] + self._components = [expr.__experiment_data__(expr) for expr in experiment._experiments] - def __repr__(self): + 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: {self.experiment_type}" ret += f"\nExperiment ID: {self.experiment_id}" - ret += "\nStatus: COMPLETE" - ret += f"\nComponent Experiments: {len(self._composite_expdata)}" + ret += f"\nStatus: {status}" + ret += f"\nComponent Experiments: {len(self._components)}" ret += f"\nCircuits: {len(self._data)}" ret += f"\nAnalysis Results: {n_res}" ret += "\n" + line @@ -51,15 +67,21 @@ def __repr__(self): ret += f"\n- {key}: {value}" return ret - def component_experiment_data(self, index): + def component_experiment_data( + self, index: Optional[Union[int, slice]] = None + ) -> Union[ExperimentData, List[ExperimentData]]: """Return component experiment data""" - return self._composite_expdata[index] + if index is None: + return self._components + if isinstance(index, (int, slice)): + return self._components[index] + raise QiskitError(f"Invalid index type {type(index)}.") def _add_single_data(self, data): """Add data to the experiment""" # TODO: Handle optional marginalizing IQ data metadata = data.get("metadata", {}) - if metadata.get("experiment_type") == self._experiment._type: + if metadata.get("experiment_type") == self._type: # Add parallel data self._data.append(data) @@ -76,4 +98,4 @@ def _add_single_data(self, data): sub_data["counts"] = marginal_counts(data["counts"], composite_clbits[i]) else: sub_data["counts"] = data["counts"] - self._composite_expdata[index].add_data(sub_data) + self._components[index].add_data(sub_data) diff --git a/qiskit_experiments/experiment_data.py b/qiskit_experiments/experiment_data.py index 91b79280e6..1d672e5d66 100644 --- a/qiskit_experiments/experiment_data.py +++ b/qiskit_experiments/experiment_data.py @@ -12,102 +12,131 @@ """ Experiment Data class """ - +import logging +from typing import Optional, Union, List, Dict, Tuple +import os import uuid +from collections import OrderedDict from qiskit.result import Result from qiskit.exceptions import QiskitError from qiskit.providers import Job, BaseJob from qiskit.providers.exceptions import JobError +try: + from matplotlib import pyplot as plt + + HAS_MATPLOTLIB = True +except ImportError: + HAS_MATPLOTLIB = False + + +LOG = logging.getLogger(__name__) + class AnalysisResult(dict): """Placeholder class""" class ExperimentData: - """ExperimentData container class""" + """Qiskit Experiments Data container class""" - def __init__(self, experiment): - """Initialize the analysis object. + def __init__( + self, + experiment: Optional["BaseExperiment"] = None, + backend: Optional[Union["Backend", "BaseBackend"]] = None, + job_ids: Optional[List[str]] = None, + ): + """Initialize experiment data. Args: - experiment (BaseExperiment): experiment object that - generated the data. + experiment: experiment object that generated the data. + backend: Backend the experiment runs on. + job_ids: IDs of jobs submitted for the experiment. + + Raises: + ExperimentError: If an input argument is invalid. """ - # Experiment identification metadata - self._id = str(uuid.uuid4()) + # Experiment class object self._experiment = experiment - # Experiment Data + # Terra ExperimentDataV1 attributes + self._backend = backend + self._id = str(uuid.uuid4()) + if experiment is not None: + self._type = experiment._type + else: + self._type = None + job_ids = job_ids or [] + self._jobs = OrderedDict((k, None) for k in job_ids) self._data = [] - - # Analysis + self._figures = OrderedDict() + self._figure_names = [] self._analysis_results = [] - def __repr__(self): - line = 51 * "-" - n_res = len(self._analysis_results) - ret = line - ret += f"\nExperiment: {self._experiment._type}" - ret += f"\nExperiment ID: {self.experiment_id}" - ret += "\nStatus: COMPLETE" - ret += f"\nCircuits: {len(self._data)}" - ret += f"\nAnalysis Results: {n_res}" - ret += "\n" + line - if n_res: - ret += "\nLast Analysis Result" - for key, value in self._analysis_results[-1].items(): - ret += f"\n- {key}: {value}" - return ret - @property - def experiment_id(self): - """Return the experiment id""" - return self._id - - def experiment(self): + def experiment(self) -> "BaseExperiment": """Return Experiment object""" return self._experiment - def analysis_result(self, index): - """Return stored analysis results - - Args: - index (int or slice): the result or range of results to return. - - Returns: - AnalysisResult: the result for an integer index. - List[AnalysisResult]: a list of results for slice index. - """ - return self._analysis_results[index] + @property + def experiment_type(self) -> str: + """Return the experiment type""" + return self._type - def add_analysis_result(self, result): - """Add an Analysis Result + @property + def experiment_id(self) -> str: + """Return the experiment id""" + return self._id - Args: - result (AnalysisResult): the analysis result to add. + @property + def job_ids(self) -> List[str]: + """Return experiment job IDs. + Returns: IDs of jobs submitted for this experiment. """ - self._analysis_results.append(result) + return list(self._jobs.keys()) @property - def data(self): - """Return stored experiment data""" - return self._data - - def add_data(self, data): - """Add data to the experiment. + def backend(self) -> Union["BaseBackend", "Backend"]: + """Return backend. + Returns: + Backend this experiment is for. + """ + return self._backend + def add_data( + self, + data: Union[Result, List[Result], Job, List[Job], Dict, List[Dict]], + ): + """Add experiment data. Args: - data (Result or dict or list): the circuit execution data - to add. This can be a Result, Job, or dict object, or a list - of Result, Job, or dict objects. + 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. Raises: - QiskitError: if the data is not a valid format. + QiskitError: if data format is invalid. KeyboardInterrupt: when job is cancelled by users. """ - if isinstance(data, dict): + # Set backend from the job, this could be added to base class + if isinstance(data, (Job, BaseJob)): + backend = data.backend() + if self.backend is not None and str(self.backend) != str(backend): + LOG.warning( + "Adding a job from a backend (%s) that is different than" + " the current ExperimentData backend (%s).", + backend, + self.backend, + ) + self._backend = backend + self._jobs[data.job_id()] = data + self._add_result_data(data.result()) + elif isinstance(data, dict): self._add_single_data(data) elif isinstance(data, (Job, BaseJob)): try: @@ -129,14 +158,18 @@ def add_data(self, data): for dat in data: self.add_data(dat) else: - raise QiskitError("Invalid data format.") + raise QiskitError(f"Invalid data type {type(data)}.") + + def _add_result_data(self, result: Result) -> None: + """Add data from a Result object - def _add_result_data(self, result: Result): - """Add data from qiskit Result object""" + Args: + result: Result object containing data to be added. + """ num_data = len(result.results) for i in range(num_data): metadata = result.results[i].header.metadata - if metadata.get("experiment_type") == self._experiment._type: + if metadata.get("experiment_type") == self._type: data = result.data(i) data["metadata"] = metadata if "counts" in data: @@ -144,12 +177,184 @@ def _add_result_data(self, result: Result): data["counts"] = result.get_counts(i) self._add_single_data(data) - def _add_single_data(self, 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: - data (dict): a data dictionary for a single circuit execution. + 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: + QiskitError: if index is invalid. """ - # This method is intended to be overriden by subclasses when necessary. - if data.get("metadata", {}).get("experiment_type") == self._experiment._type: - self._data.append(data) + if index is None: + return self._data + 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 QiskitError(f"Invalid index type {type(index)}.") + + def add_figure( + self, + figure: Union[str, bytes, "Figure"], + figure_name: Optional[str] = None, + overwrite: bool = False, + ) -> Tuple[str, int]: + """Save the experiment figure. + + Args: + figure: Name of the figure file or figure data to store. + figure_name: Name of the figure. If ``None``, use the figure file name, if + given, or a generated name. + overwrite: Whether to overwrite the figure if one already exists with + the same name. + + Returns: + A tuple of the name and size of the saved figure. Returned size + is 0 if there is no experiment service to use. + + Raises: + QiskitError: If the figure with the same name already exists, + and `overwrite=True` is not specified. + """ + if not figure_name: + if isinstance(figure, str): + figure_name = figure + else: + figure_name = f"figure_{self.experiment_id}_{len(self.figure_names)}" + + existing_figure = figure_name in self._figure_names + if existing_figure and not overwrite: + raise QiskitError( + f"A figure with the name {figure_name} for this experiment " + f"already exists. Specify overwrite=True if you " + f"want to overwrite it." + ) + out = [figure_name, 0] + self._figures[figure_name] = figure + self._figure_names.append(figure_name) + return out + + def figure( + self, figure_name: Union[str, int], file_name: Optional[str] = None + ) -> Union[int, bytes, "Figure"]: + """Retrieve the specified experiment figure. + + Args: + figure_name: Name of the figure or figure position. + 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: + QiskitError: If the figure cannot be found. + """ + if isinstance(figure_name, int): + figure_name = self._figure_names[figure_name] + + figure_data = self._figures.get(figure_name, None) + if figure_data is not None: + if isinstance(figure_data, str): + with open(figure_data, "rb") as file: + figure_data = file.read() + if file_name: + with open(file_name, "wb") as output: + if HAS_MATPLOTLIB and isinstance(figure_data, plt.Figure): + figure_data.savefig(output, format="svg") + num_bytes = os.path.getsize(file_name) + else: + num_bytes = output.write(figure_data) + return num_bytes + return figure_data + raise QiskitError(f"Figure {figure_name} not found.") + + @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._figure_names + + def add_analysis_result(self, result: AnalysisResult) -> None: + """Save the analysis result. + Args: + result: Analysis result to be saved. + """ + self._analysis_results.append(result) + + def analysis_result( + self, index: Optional[Union[int, slice, str]] + ) -> 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. + + Returns: + Analysis results for this experiment. + + Raises: + QiskitError: if index is invalid. + """ + if index is None: + return self._analysis_results + if isinstance(index, (int, slice)): + return self._analysis_results[index] + if isinstance(index, str): + for res in self._analysis_results: + if res.id == index: + return res + raise QiskitError(f"Analysis result {index} not found.") + raise QiskitError(f"Invalid index type {type(index)}.") + + def status(self) -> str: + """Return the data processing status. + + Returns: + Data processing status. + """ + # TODO: Figure out what statuses should be returned including + # execution and analysis status + if not self._jobs and not self._data: + return "EMPTY" + return "DONE" + + def __str__(self): + line = 51 * "-" + n_res = len(self._analysis_results) + ret = line + ret += f"\nExperiment: {self.experiment_type}" + ret += f"\nExperiment ID: {self.experiment_id}" + ret += f"\nStatus: {self.status()}" + ret += f"\nCircuits: {len(self._data)}" + ret += f"\nAnalysis Results: {n_res}" + ret += "\n" + line + if n_res: + ret += "\nLast Analysis Result" + for key, value in self._analysis_results[-1].items(): + ret += f"\n- {key}: {value}" + return ret diff --git a/qiskit_experiments/randomized_benchmarking/rb_analysis.py b/qiskit_experiments/randomized_benchmarking/rb_analysis.py index dfcdba9a4e..ccf6d5c173 100644 --- a/qiskit_experiments/randomized_benchmarking/rb_analysis.py +++ b/qiskit_experiments/randomized_benchmarking/rb_analysis.py @@ -21,14 +21,12 @@ level2_probability, mean_xy_data, ) -from qiskit_experiments.analysis.plotting import plot_curve_fit, plot_scatter, plot_errorbar - -try: - from matplotlib import pyplot as plt - - HAS_MATPLOTLIB = True -except ImportError: - HAS_MATPLOTLIB = False +from qiskit_experiments.analysis.plotting import ( + HAS_MATPLOTLIB, + plot_curve_fit, + plot_scatter, + plot_errorbar, +) class RBAnalysis(BaseAnalysis): @@ -37,14 +35,14 @@ class RBAnalysis(BaseAnalysis): # pylint: disable = arguments-differ, invalid-name def _run_analysis( self, - experiment_data, + experiment_data: "ExperimentData", p0: Optional[List[float]] = None, plot: bool = True, ax: Optional["AxesSubplot"] = None, ): """Run analysis on circuit data. Args: - experiment_data (ExperimentData): the experiment data to analyze. + experiment_data: the experiment data to analyze. p0: Optional, initial parameter values for curve_fit. plot: If True generate a plot of fitted data. ax: Optional, matplotlib axis to add plot to. @@ -54,16 +52,15 @@ def _run_analysis( AnalysisResult objects, and ``figures`` may be None, a single figure, or a list of figures. """ - num_qubits = len(experiment_data.data[0]["metadata"]["qubits"]) + data = experiment_data.data() + num_qubits = len(data[0]["metadata"]["qubits"]) # Process data def data_processor(datum): return level2_probability(datum, num_qubits * "0") # Raw data for each sample - x_raw, y_raw, sigma_raw = process_curve_data( - experiment_data.data, data_processor, x_key="xdata" - ) + x_raw, y_raw, sigma_raw = process_curve_data(data, data_processor, x_key="xdata") # Data averaged over samples xdata, ydata, ydata_sigma = mean_xy_data(x_raw, y_raw, sigma_raw, method="sample") @@ -83,13 +80,15 @@ def fit_fun(x, a, alpha, b): analysis_result["EPC"] = scale * (1 - popt[1]) analysis_result["EPC_err"] = scale * popt_err[1] / popt[1] - if plot: + if plot and HAS_MATPLOTLIB: ax = plot_curve_fit(fit_fun, analysis_result, ax=ax) ax = plot_scatter(x_raw, y_raw, ax=ax) ax = plot_errorbar(xdata, ydata, ydata_sigma, ax=ax) self._format_plot(ax, analysis_result) - analysis_result.plt = plt - return analysis_result, None + figures = [ax.get_figure()] + else: + figures = None + return analysis_result, figures @staticmethod def _p0(xdata, ydata, num_qubits): diff --git a/test/data_processing/test_data_processing.py b/test/data_processing/test_data_processing.py index 4dfc36f147..0322a83365 100644 --- a/test/data_processing/test_data_processing.py +++ b/test/data_processing/test_data_processing.py @@ -113,10 +113,10 @@ def test_empty_processor(self): """Check that a DataProcessor without steps does nothing.""" data_processor = DataProcessor("counts") - datum = data_processor(self.exp_data_lvl2.data[0]) + datum = data_processor(self.exp_data_lvl2.data(0)) self.assertEqual(datum, {"00": 4, "10": 6}) - datum, history = data_processor.call_with_history(self.exp_data_lvl2.data[0]) + datum, history = data_processor.call_with_history(self.exp_data_lvl2.data(0)) self.assertEqual(datum, {"00": 4, "10": 6}) self.assertEqual(history, []) @@ -127,7 +127,7 @@ def test_to_real(self): exp_data = ExperimentData(FakeExperiment()) exp_data.add_data(self.result_lvl1) - new_data = processor(exp_data.data[0]) + new_data = processor(exp_data.data(0)) expected_old = { "memory": [ @@ -140,13 +140,13 @@ def test_to_real(self): expected_new = np.array([[1103.26, 2959.012], [442.17, -5279.41], [3016.514, -3404.7560]]) - self.assertEqual(exp_data.data[0], expected_old) + self.assertEqual(exp_data.data(0), expected_old) self.assertTrue(np.allclose(new_data, expected_new)) # Test that we can call with history. - new_data, history = processor.call_with_history(exp_data.data[0]) + new_data, history = processor.call_with_history(exp_data.data(0)) - self.assertEqual(exp_data.data[0], expected_old) + self.assertEqual(exp_data.data(0), expected_old) self.assertTrue(np.allclose(new_data, expected_new)) self.assertEqual(history[0][0], "ToReal") @@ -160,7 +160,7 @@ def test_to_imag(self): exp_data = ExperimentData(FakeExperiment()) exp_data.add_data(self.result_lvl1) - new_data = processor(exp_data.data[0]) + new_data = processor(exp_data.data(0)) expected_old = { "memory": [ @@ -179,12 +179,12 @@ def test_to_imag(self): ] ) - self.assertEqual(exp_data.data[0], expected_old) + self.assertEqual(exp_data.data(0), expected_old) self.assertTrue(np.allclose(new_data, expected_new)) # Test that we can call with history. - new_data, history = processor.call_with_history(exp_data.data[0]) - self.assertEqual(exp_data.data[0], expected_old) + new_data, history = processor.call_with_history(exp_data.data(0)) + self.assertEqual(exp_data.data(0), expected_old) self.assertTrue(np.allclose(new_data, expected_new)) self.assertEqual(history[0][0], "ToImag") @@ -196,7 +196,7 @@ def test_populations(self): processor = DataProcessor("counts") processor.append(Probability("00")) - new_data = processor(self.exp_data_lvl2.data[0]) + new_data = processor(self.exp_data_lvl2.data(0)) self.assertEqual(new_data[0], 0.4) self.assertEqual(new_data[1], 0.4 * (1 - 0.4) / 10) @@ -278,7 +278,7 @@ def test_avg_and_single(self): imag_avg = DataProcessor("memory", [ToImagAvg(scale=1)]) # Test the real single shot node - new_data = real_single(self.exp_data_single.data[0]) + new_data = real_single(self.exp_data_single.data(0)) expected = np.array( [ [-56470872.0, -53407256.0], @@ -292,10 +292,10 @@ def test_avg_and_single(self): self.assertTrue(np.allclose(new_data, expected)) with self.assertRaises(DataProcessorError): - real_single(self.exp_data_avg.data[0]) + real_single(self.exp_data_avg.data(0)) # Test the imaginary single shot node - new_data = imag_single(self.exp_data_single.data[0]) + new_data = imag_single(self.exp_data_single.data(0)) expected = np.array( [ [-136691568.0, -176278624.0], @@ -309,12 +309,12 @@ def test_avg_and_single(self): self.assertTrue(np.allclose(new_data, expected)) # Test the real average node - new_data = real_avg(self.exp_data_avg.data[0]) + new_data = real_avg(self.exp_data_avg.data(0)) self.assertTrue(np.allclose(new_data, np.array([-539698.0, 5541283.0]))) # Test the imaginary average node - new_data = imag_avg(self.exp_data_avg.data[0]) + new_data = imag_avg(self.exp_data_avg.data(0)) self.assertTrue(np.allclose(new_data, np.array([-153030784.0, -160369600.0]))) with self.assertRaises(DataProcessorError): - real_avg(self.exp_data_single.data[0]) + real_avg(self.exp_data_single.data(0))