From 0bf1507d05ccc7b4ba79f01389ded890aa6d3483 Mon Sep 17 00:00:00 2001 From: knzwnao Date: Sat, 29 Jan 2022 06:54:37 +0900 Subject: [PATCH] Add AnalysisResult class - AnalysisResultData class has been deprecated - ExperimentData.load is updated to directly instantiate own class - DbExperimentData._analysis_res_cls attribute is added to instantiate proper analysis container AnalysisResult class implements _from_service_data method that implements formatter for FitVal. UFloat value is converted into FitVal just before data saving and converted back into UFloat in the loader. Covariance info will be lost in round trip. FitVal is still supported as value container for UFloat in database service. FakeService unittest class is also udpated to support storing the analysis results and several unittests for AnalysisResult class are also added. --- .../curve_analysis/curve_analysis.py | 14 +- .../visualization/fit_result_plotters.py | 8 +- .../database_service/db_analysis_result.py | 2 +- .../database_service/db_experiment_data.py | 10 +- .../database_service/db_fitval.py | 9 - .../database_service/device_component.py | 28 ++ qiskit_experiments/framework/__init__.py | 5 +- .../framework/analysis_result_data.py | 319 ++++++++++++++++-- qiskit_experiments/framework/base_analysis.py | 35 +- .../framework/experiment_data.py | 41 ++- .../analysis/cr_hamiltonian_analysis.py | 7 +- .../analysis/readout_angle_analysis.py | 4 +- .../library/mitigation/mitigation_analysis.py | 10 +- .../library/quantum_volume/qv_analysis.py | 8 +- .../interleaved_rb_analysis.py | 6 +- .../randomized_benchmarking/rb_analysis.py | 10 +- .../randomized_benchmarking/rb_utils.py | 4 +- .../library/tomography/tomography_analysis.py | 10 +- test/fake_experiment.py | 4 +- test/fake_service.py | 23 +- test/randomized_benchmarking/test_rb_utils.py | 14 +- test/test_framework.py | 150 +++++++- 22 files changed, 600 insertions(+), 121 deletions(-) diff --git a/qiskit_experiments/curve_analysis/curve_analysis.py b/qiskit_experiments/curve_analysis/curve_analysis.py index ce165b3723..d98aeb4bbe 100644 --- a/qiskit_experiments/curve_analysis/curve_analysis.py +++ b/qiskit_experiments/curve_analysis/curve_analysis.py @@ -43,7 +43,7 @@ from qiskit_experiments.framework import ( BaseAnalysis, ExperimentData, - AnalysisResultData, + AnalysisResult, Options, ) @@ -203,7 +203,7 @@ class AnalysisExample(CurveAnalysis): - Create extra data from fit result: Override :meth:`~self._extra_database_entry`. You need to return a list of - :class:`~qiskit_experiments.framework.analysis_result_data.AnalysisResultData` + :class:`~qiskit_experiments.framework.analysis_result_data.AnalysisResult` object. This returns an empty list by default. - Customize fit quality evaluation: @@ -496,7 +496,7 @@ def _format_data(self, data: CurveData) -> CurveData: ) # pylint: disable=unused-argument - def _extra_database_entry(self, fit_data: FitData) -> List[AnalysisResultData]: + def _extra_database_entry(self, fit_data: FitData) -> List[AnalysisResult]: """Calculate new quantity from the fit result. Subclasses can override this method to do post analysis. @@ -746,7 +746,7 @@ def _data( def _run_analysis( self, experiment_data: ExperimentData - ) -> Tuple[List[AnalysisResultData], List["pyplot.Figure"]]: + ) -> Tuple[List[AnalysisResult], List["pyplot.Figure"]]: # # 1. Parse arguments # @@ -868,7 +868,7 @@ def _run_analysis( # overview entry analysis_results.append( - AnalysisResultData( + AnalysisResult( name=PARAMS_ENTRY_PREFIX + self.__class__.__name__, value=[p.nominal_value for p in fit_result.popt], chisq=fit_result.reduced_chisq, @@ -895,7 +895,7 @@ def _run_analysis( p_name = param_repr p_repr = param_repr unit = None - result_entry = AnalysisResultData( + result_entry = AnalysisResult( name=p_repr, value=fit_result.fitval(p_name), unit=unit, @@ -918,7 +918,7 @@ def _run_analysis( "ydata": series_data.y, "sigma": series_data.y_err, } - raw_data_entry = AnalysisResultData( + raw_data_entry = AnalysisResult( name=DATA_ENTRY_PREFIX + self.__class__.__name__, value=raw_data_dict, extra={ diff --git a/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py b/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py index 37c6be9812..72abf3ed3c 100644 --- a/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py +++ b/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py @@ -30,7 +30,7 @@ from qiskit.utils import detach_prefix from qiskit_experiments.curve_analysis.curve_data import SeriesDef, FitData, CurveData -from qiskit_experiments.framework import AnalysisResultData +from qiskit_experiments.framework import AnalysisResult from qiskit_experiments.framework.matplotlib import get_non_gui_ax from .curves import plot_scatter, plot_errorbar, plot_curve_fit from .style import PlotterStyle @@ -47,7 +47,7 @@ def draw( fit_samples: List[CurveData], tick_labels: Dict[str, str], fit_data: FitData, - result_entries: List[AnalysisResultData], + result_entries: List[AnalysisResult], style: Optional[PlotterStyle] = None, axis: Optional["matplotlib.axes.Axes"] = None, ) -> "pyplot.Figure": @@ -146,7 +146,7 @@ def draw( fit_samples: List[CurveData], tick_labels: Dict[str, str], fit_data: FitData, - result_entries: List[AnalysisResultData], + result_entries: List[AnalysisResult], style: Optional[PlotterStyle] = None, axis: Optional["matplotlib.axes.Axes"] = None, ) -> "pyplot.Figure": @@ -336,7 +336,7 @@ def draw_single_curve_mpl( ) -def write_fit_report(result_entries: List[AnalysisResultData]) -> str: +def write_fit_report(result_entries: List[AnalysisResult]) -> str: """A function that generates fit reports documentation from list of data. Args: diff --git a/qiskit_experiments/database_service/db_analysis_result.py b/qiskit_experiments/database_service/db_analysis_result.py index 6a5f8286ba..3dc67010fa 100644 --- a/qiskit_experiments/database_service/db_analysis_result.py +++ b/qiskit_experiments/database_service/db_analysis_result.py @@ -203,7 +203,7 @@ def save(self) -> None: def copy(self) -> "DbAnalysisResultV1": """Return a copy of the result with a new result ID""" - return DbAnalysisResultV1( + return self.__class__( name=self.name, value=self.value, device_components=self.device_components, diff --git a/qiskit_experiments/database_service/db_experiment_data.py b/qiskit_experiments/database_service/db_experiment_data.py index 9c316859c4..6ac59cb665 100644 --- a/qiskit_experiments/database_service/db_experiment_data.py +++ b/qiskit_experiments/database_service/db_experiment_data.py @@ -128,6 +128,7 @@ class DbExperimentDataV1(DbExperimentData): verbose = True # Whether to print messages to the standard output. _metadata_version = 1 _job_executor = futures.ThreadPoolExecutor() + _analysis_res_cls = DbAnalysisResult _json_encoder = ExperimentEncoder _json_decoder = ExperimentDecoder @@ -812,7 +813,7 @@ def _retrieve_analysis_results(self, refresh: bool = False): ) for result in retrieved_results: result_id = result["result_id"] - self._analysis_results[result_id] = DbAnalysisResult._from_service_data(result) + self._analysis_results[result_id] = self._analysis_res_cls._from_service_data(result) def analysis_results( self, @@ -1008,10 +1009,7 @@ def load(cls, experiment_id: str, service: DatabaseServiceV1) -> "DbExperimentDa service_data = service.experiment(experiment_id, json_decoder=cls._json_decoder) # Parse serialized metadata - metadata = service_data.pop("metadata") - - # Initialize container - expdata = DbExperimentDataV1( + expdata = cls( experiment_type=service_data.pop("experiment_type"), backend=service_data.pop("backend"), experiment_id=service_data.pop("experiment_id"), @@ -1019,7 +1017,7 @@ def load(cls, experiment_id: str, service: DatabaseServiceV1) -> "DbExperimentDa tags=service_data.pop("tags"), job_ids=service_data.pop("job_ids"), share_level=service_data.pop("share_level"), - metadata=metadata, + metadata=service_data.pop("metadata"), figure_names=service_data.pop("figure_names"), notes=service_data.pop("notes"), **service_data, diff --git a/qiskit_experiments/database_service/db_fitval.py b/qiskit_experiments/database_service/db_fitval.py index 31eff96c8f..04c0fdb8cc 100644 --- a/qiskit_experiments/database_service/db_fitval.py +++ b/qiskit_experiments/database_service/db_fitval.py @@ -13,7 +13,6 @@ """DB class for fit value with std error and unit.""" import dataclasses -import warnings from typing import Optional @@ -34,11 +33,3 @@ def __str__(self): if self.unit is not None: out += f" {str(self.unit)}" return out - - -def __new__(cls, *args, **kwargs): - warnings.warn( - "FitVal object has been deprecated in Qiskit Experiments version 0.3 and " - "will be removed in version 0.5. Use version <= 0.3 to load this object.", - DeprecationWarning, - ) diff --git a/qiskit_experiments/database_service/device_component.py b/qiskit_experiments/database_service/device_component.py index 8c602f94dd..4f5463a619 100644 --- a/qiskit_experiments/database_service/device_component.py +++ b/qiskit_experiments/database_service/device_component.py @@ -12,6 +12,7 @@ """Device component classes.""" +from typing import Dict, Any from abc import ABC, abstractmethod @@ -35,6 +36,15 @@ def __init__(self, index: int): def __str__(self): return f"Q{self._index}" + def __json_encode__(self): + """Convert to format that can be JSON serialized.""" + return {"index": self._index} + + @classmethod + def __json_decode__(cls, value: Dict[str, Any]) -> "Qubit": + """Load from JSON compatible format.""" + return cls(**value) + class Resonator(DeviceComponent): """Class representing a resonator device component.""" @@ -45,6 +55,15 @@ def __init__(self, index: int): def __str__(self): return f"R{self._index}" + def __json_encode__(self): + """Convert to format that can be JSON serialized.""" + return {"index": self._index} + + @classmethod + def __json_decode__(cls, value: Dict[str, Any]) -> "Resonator": + """Load from JSON compatible format.""" + return cls(**value) + class UnknownComponent(DeviceComponent): """Class representing unknown device component.""" @@ -55,6 +74,15 @@ def __init__(self, component: str): def __str__(self): return self._component + def __json_encode__(self): + """Convert to format that can be JSON serialized.""" + return {"component": self._component} + + @classmethod + def __json_decode__(cls, value: Dict[str, Any]) -> "UnknownComponent": + """Load from JSON compatible format.""" + return cls(**value) + def to_component(string: str) -> DeviceComponent: """Convert the input string to a ``DeviceComponent`` instance. diff --git a/qiskit_experiments/framework/__init__.py b/qiskit_experiments/framework/__init__.py index 656c815f85..b10617cb08 100644 --- a/qiskit_experiments/framework/__init__.py +++ b/qiskit_experiments/framework/__init__.py @@ -187,7 +187,7 @@ The :meth:`BaseAnalysis._run_analysis` method should return a pair ``(results, figures)`` where ``results`` is a list of -:class:`AnalysisResultData` and ``figures`` is a list of +:class:`AnalysisResult` and ``figures`` is a list of :class:`matplotlib.figure.Figure`. The :mod:`qiskit_experiments.data_processing` module contains classes for @@ -207,6 +207,7 @@ JobStatus AnalysisStatus FitVal + AnalysisResult AnalysisResultData ExperimentConfig AnalysisConfig @@ -246,7 +247,7 @@ from .base_analysis import BaseAnalysis from .base_experiment import BaseExperiment from .configs import ExperimentConfig, AnalysisConfig -from .analysis_result_data import AnalysisResultData +from .analysis_result_data import AnalysisResultData, AnalysisResult from .experiment_data import ExperimentData from .composite import ( ParallelExperiment, diff --git a/qiskit_experiments/framework/analysis_result_data.py b/qiskit_experiments/framework/analysis_result_data.py index b4d6f6aac6..3b6277a1e5 100644 --- a/qiskit_experiments/framework/analysis_result_data.py +++ b/qiskit_experiments/framework/analysis_result_data.py @@ -12,38 +12,315 @@ """Helper dataclass for constructing analysis results.""" -import dataclasses import logging +import warnings from typing import Optional, Dict, Any, List +from uncertainties import UFloat, ufloat + +from qiskit_experiments.database_service import DbAnalysisResultV1 +from qiskit_experiments.database_service.db_fitval import FitVal +from qiskit_experiments.database_service.device_component import DeviceComponent, to_component +from qiskit_experiments.database_service.exceptions import DbExperimentDataError + LOG = logging.getLogger(__name__) -@dataclasses.dataclass -class AnalysisResultData: - """Dataclass for experiment analysis results""" +class AnalysisResult(DbAnalysisResultV1): + """Qiskit Experiments Analysis Result container class. + + This object is intended to be used for storing result of analysis. + Thus class can be instantiated without experiment metadata nor provider information. + Missing information can be set later if user want to save the data to database. + + This class also supports expression of the analysis value with the UFloat object. + UFloat objects are implicitly converted into :class:`FitVal` + for serialization when the entry is saved in the database. + """ + # TODO remove provider API from this class. This should be simple container for analysis. + + def __init__( + self, + name: str, + value: Any, + unit: Optional[str] = None, + chisq: Optional[float] = None, + quality: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + device_components: Optional[List[DeviceComponent]] = None, + ): + """Create new result object. + + Args: + name: Name of the quantity saved in this container. + value: Value from the analysis. + unit: Physical unit of the value if exist. + chisq: Reduced Chi-squared value if exist. + quality: Quality of analysis. + extra: Metadata associated to the analysis. + device_components: Target device components for this analysis result + """ + super().__init__( + name=name, + value=value, + device_components=device_components or list(), + experiment_id="", + chisq=chisq, + quality=quality, + extra=extra, + ) + self._unit = unit + + @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 + + @experiment_id.setter + def experiment_id(self, new_id: str): + """Set new experiment id if not exist. + + Args: + new_id: ID of experiment associated with this analysis result. + """ + if not self.experiment_id: + self._experiment_id = new_id + else: + warnings.warn( + "Experiment ID cannot be overridden. Create new object to set new value.", + UserWarning, + ) + + @property + def device_components(self) -> List[DeviceComponent]: + """Return target device components for this analysis result. + + Returns: + Target device components. + """ + return self._device_components + + @device_components.setter + def device_components(self, new_components: List[DeviceComponent]): + """Set new target device components if not exist. + + Args: + new_components: Target device components. + """ + if not self.device_components: + self._device_components = [ + to_component(comp) if isinstance(comp, str) else comp for comp in new_components + ] + else: + warnings.warn( + "Device components cannot be overridden. Create new object to set new value.", + UserWarning, + ) + + @property + def unit(self): + """Return physical unit of value stored in this container. + + Returns: + Physical unit of analysis value. + """ + return self._unit + + @unit.setter + def unit(self, new_unit: str): + """Set new unit. + + Args: + new_unit: Physical unit of analysis value. + """ + self._unit = new_unit + + def save(self): + """Save this analysis result in the database. - # TODO: move stderr and unit into custom value class - name: str - value: Any - chisq: Optional[float] = None - quality: Optional[str] = None - extra: Dict[str, Any] = dataclasses.field(default_factory=dict, hash=False, compare=False) - device_components: List = dataclasses.field(default_factory=list) + Raises: + DbExperimentDataError: When the experiment metadata is not set. + """ + if isinstance(self.value, UFloat): + db_value = FitVal( + value=self.value.nominal_value, + stderr=self.value.std_dev, + unit=self._unit, + ) + else: + if self.unit: + db_value = FitVal(value=self.value, unit=self.unit) + else: + db_value = self.value + + if not self.experiment_id or not self.device_components: + raise DbExperimentDataError( + "Required fields are missing. " + "Set self.experiment_id and self.device_components from the experiment." + ) + + db_analysis_result = DbAnalysisResultV1( + name=self.name, + value=db_value, + device_components=self.device_components, + experiment_id=self.experiment_id, + result_id=self.result_id, + chisq=self.chisq, + quality=self.quality, + extra=self.extra, + verified=self.verified, + tags=self.tags, + service=self.service, + source=self.source, + ) + db_analysis_result.save() + + @classmethod + def _from_service_data(cls, service_data: Dict) -> "AnalysisResult": + """Construct an analysis result from saved database service data. + + Args: + service_data: Analysis result data. + + Returns: + The loaded analysis result. + """ + result_data = service_data.pop("result_data") + db_value = result_data.pop("_value") + chisq = result_data.pop("_chisq", None) + extra = result_data.pop("_extra", {}) + source = result_data.pop("_source", None) + + if isinstance(db_value, FitVal): + value = ufloat(db_value.value, db_value.stderr) + unit = db_value.unit + else: + value = db_value + unit = None + + obj = cls( + name=service_data.pop("result_type"), + value=value, + unit=unit, + chisq=chisq, + quality=service_data.pop("quality"), + extra=extra, + ) + + # private properties + obj._id = service_data.pop("result_id") + obj._source = source + + # with setters + obj.experiment_id = service_data.pop("experiment_id") + obj.device_components = service_data.pop("device_components") + obj.tags = service_data.pop("tags") + obj.verified = service_data.pop("verified") + obj.service = service_data.pop("service") + + # TODO this should not exist. + # We should be able to define __slots__ for memory efficiency + for key, val in service_data.items(): + setattr(obj, key, val) + return obj + + def copy(self) -> "AnalysisResult": + """Return a copy of the result with a new result ID""" + new_obj = AnalysisResult( + name=self.name, + value=self.value, + unit=self.unit, + chisq=self.chisq, + quality=self.quality, + extra=self.extra.copy(), + ) + new_obj.experiment_id = self.experiment_id + new_obj.device_components = self.device_components + new_obj.tags = self.tags + new_obj.verified = self.verified + new_obj.service = self.service + + new_obj._id = self.result_id + new_obj._source = self.source + + return new_obj def __str__(self): - out = f"{self.name}:" - out += f"\n- value:{self.value}" + ret = f"{type(self).__name__}" + ret += f"\n- name: {self.name}" + ret += f"\n- value: {str(self.value)}" + if self.unit is not None: + ret += f" [{self.unit}]" if self.chisq is not None: - out += f"\n- chisq: {self.chisq}" + ret += f"\n- χ²: {str(self.chisq)}" if self.quality is not None: - out += f"\n- quality: {self.quality}" + ret += f"\n- quality: {self.quality}" if self.extra: - out += f"\n- extra: <{len(self.extra)} items>" - if self.device_components: - out += f"\n- device_components: {[str(i) for i in self.device_components]}" + ret += f"\n- extra: <{len(self.extra)} items>" + ret += f"\n- device_components: {[str(i) for i in self.device_components]}" + ret += f"\n- verified: {self.verified}" + return ret + + def __repr__(self): + out = f"{type(self).__name__}(" + out += f"name={self.name}" + out += f", value={repr(self.value)}" + out += f", unit={self.unit}" + out += f", device_components={repr(self.device_components)}" + out += f", experiment_id={self.experiment_id}" + out += f", result_id={self.result_id}" + out += f", chisq={self.chisq}" + out += f", quality={self.quality}" + out += f", verified={self.verified}" + out += f", extra={repr(self.extra)}" + out += f", tags={self.tags}" + out += f", service={repr(self.experiment_id)}" + for key, val in self._extra_data.items(): + out += f", {key}={repr(val)}" + out += ")" return out - def __iter__(self): - """Return iterator of data fields (attr, value)""" - return iter((field.name, getattr(self, field.name)) for field in dataclasses.fields(self)) + +class AnalysisResultData(AnalysisResult): + """Deprecated. A container for experiment analysis results""" + + def __new__( + cls, + name: str, + value: Any, + chisq: Optional[float] = None, + quality: Optional[str] = None, + extra: Optional[Dict[str, str]] = None, + device_components: Optional[List[DeviceComponent]] = None, + ): + """Instantiate new AnalysisResult class. This class is deprecated. + + Args: + name: Name of the quantity saved in this container. + value: Value from the analysis. + chisq: Reduced Chi-squared value if exist. + quality: Quality of analysis. + extra: Metadata associated to the analysis. + device_components: Target device components. + """ + warnings.warn( + "AnalysisResultData has been deprecated in Qiskit Experiments 0.3 and " + "will be removed in 0.4 release. Use AnalysisResult class instead.", + DeprecationWarning, + stacklevel=2, + ) + + instance = AnalysisResult( + name=name, + value=value, + chisq=chisq, + quality=quality, + extra=extra, + device_components=device_components, + ) + + return instance diff --git a/qiskit_experiments/framework/base_analysis.py b/qiskit_experiments/framework/base_analysis.py index fd0d70ca4e..b1a263fffd 100644 --- a/qiskit_experiments/framework/base_analysis.py +++ b/qiskit_experiments/framework/base_analysis.py @@ -23,8 +23,8 @@ from qiskit_experiments.framework.store_init_args import StoreInitArgs from qiskit_experiments.framework.experiment_data import ExperimentData from qiskit_experiments.framework.configs import AnalysisConfig -from qiskit_experiments.framework.analysis_result_data import AnalysisResultData -from qiskit_experiments.database_service import DbAnalysisResultV1 +from qiskit_experiments.framework.analysis_result_data import AnalysisResult +from qiskit_experiments.database_service.device_component import DeviceComponent class BaseAnalysis(ABC, StoreInitArgs): @@ -179,30 +179,23 @@ def run_analysis(expdata): return experiment_data - def _format_analysis_result(self, data, experiment_id, experiment_components=None): + def _format_analysis_result( + self, + data: AnalysisResult, + experiment_id: str, + experiment_components: List[Union[str, DeviceComponent]] = None, + ): """Format run analysis result to DbAnalysisResult""" - device_components = [] - if data.device_components: - device_components = data.device_components - elif experiment_components: - device_components = experiment_components - - return DbAnalysisResultV1( - name=data.name, - value=data.value, - device_components=device_components, - experiment_id=experiment_id, - chisq=data.chisq, - quality=data.quality, - unit=data.unit, - extra=data.extra, - ) + data.experiment_id = experiment_id + if not data.device_components: + data.device_components = experiment_components + return data @abstractmethod def _run_analysis( self, experiment_data: ExperimentData, - ) -> Tuple[List[AnalysisResultData], List["matplotlib.figure.Figure"]]: + ) -> Tuple[List[AnalysisResult], List["matplotlib.figure.Figure"]]: """Run analysis on circuit data. Args: @@ -210,7 +203,7 @@ def _run_analysis( Returns: A pair ``(analysis_results, figures)`` where ``analysis_results`` - is a list of :class:`AnalysisResultData` objects, and ``figures`` + is a list of :class:`AnalysisResult` objects, and ``figures`` is a list of any figures for the experiment. Raises: diff --git a/qiskit_experiments/framework/experiment_data.py b/qiskit_experiments/framework/experiment_data.py index df7b3d2969..86057964b6 100644 --- a/qiskit_experiments/framework/experiment_data.py +++ b/qiskit_experiments/framework/experiment_data.py @@ -24,6 +24,7 @@ DatabaseServiceV1 as DatabaseService, ) from qiskit_experiments.database_service.utils import ThreadSafeOrderedDict +from qiskit_experiments.framework.analysis_result_data import AnalysisResult if TYPE_CHECKING: # There is a cyclical dependency here, but the name needs to exist for @@ -37,6 +38,7 @@ class ExperimentData(DbExperimentData): """Qiskit Experiments Data container class""" + _analysis_res_cls = AnalysisResult def __init__( self, @@ -161,12 +163,45 @@ def _save_experiment_metadata(self): @classmethod def load(cls, experiment_id: str, service: DatabaseService) -> ExperimentData: - expdata = DbExperimentData.load(experiment_id, service) - expdata.__class__ = ExperimentData - expdata._experiment = None + service_data = service.experiment(experiment_id, json_decoder=cls._json_decoder) + metadata = service_data.pop("metadata") + source = metadata.pop("_source") + + expdata = ExperimentData( + backend=service_data.pop("backend"), + parent_id=service_data.pop("parent_id", None), + job_ids=service_data.pop("job_ids"), + ) + expdata._type = service_data.pop("experiment_type") + expdata._id = service_data.pop("experiment_id") + expdata._metadata = metadata + expdata._source = source + expdata._figures = ThreadSafeOrderedDict(service_data.pop("figure_names")) + expdata._share_level = service_data.pop("share_level") + expdata._created_in_db = True + + expdata.tags = service_data.pop("tags") + expdata.notes = service_data.pop("notes") + expdata._extra_data = service_data + + if expdata.service is None: + expdata.service = service + + # retrieve job data + expdata._retrieve_data() + + # This method should be called from proper child class to instantiate + # the expected analysis container. + # The analysis result container type is defined in cls._analysis_res_cls + # Note that just swapping class type doesn't work in this case + # because AnalysisResult class implements formatter for FitVal in own loader. + expdata._retrieve_analysis_results() + + # load child data child_data_ids = expdata.metadata.pop("child_data_ids", []) child_data = [ExperimentData.load(child_id, service) for child_id in child_data_ids] expdata._set_child_data(child_data) + return expdata def copy(self, copy_results=True) -> "ExperimentData": diff --git a/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py b/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py index 532595abf8..91cd790a0d 100644 --- a/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py @@ -22,7 +22,7 @@ import qiskit_experiments.data_processing as dp from qiskit_experiments.database_service.device_component import Qubit from qiskit_experiments.exceptions import AnalysisError -from qiskit_experiments.framework import AnalysisResultData +from qiskit_experiments.framework import AnalysisResult # pylint: disable=line-too-long @@ -322,7 +322,7 @@ def _evaluate_quality(self, fit_data: curve.FitData) -> Union[str, None]: return "bad" - def _extra_database_entry(self, fit_data: curve.FitData) -> List[AnalysisResultData]: + def _extra_database_entry(self, fit_data: curve.FitData) -> List[AnalysisResult]: """Calculate Hamiltonian coefficients from fit values.""" extra_entries = [] @@ -337,12 +337,11 @@ def _extra_database_entry(self, fit_data: curve.FitData) -> List[AnalysisResultD coef_val = 0.5 * (p0_val + p1_val) / (2 * np.pi) extra_entries.append( - AnalysisResultData( + AnalysisResult( name=f"omega_{control}{target}", value=coef_val, unit="Hz", chisq=fit_data.reduced_chisq, - device_components=[Qubit(q) for q in self._physical_qubits], ) ) diff --git a/qiskit_experiments/library/characterization/analysis/readout_angle_analysis.py b/qiskit_experiments/library/characterization/analysis/readout_angle_analysis.py index d2c674296d..98ba88bad4 100644 --- a/qiskit_experiments/library/characterization/analysis/readout_angle_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/readout_angle_analysis.py @@ -16,7 +16,7 @@ from typing import List, Optional import numpy as np -from qiskit_experiments.framework import BaseAnalysis, AnalysisResultData, Options +from qiskit_experiments.framework import BaseAnalysis, AnalysisResult, Options from qiskit_experiments.framework.matplotlib import get_non_gui_ax @@ -59,7 +59,7 @@ def _run_analysis(self, experiment_data): extra_results["readout_radius_1"] = radii[1] analysis_results = [ - AnalysisResultData(name="readout_angle", value=angle, extra=extra_results) + AnalysisResult(name="readout_angle", value=angle, extra=extra_results) ] if self.options.plot: diff --git a/qiskit_experiments/library/mitigation/mitigation_analysis.py b/qiskit_experiments/library/mitigation/mitigation_analysis.py index 8ec1049044..29d2ce282b 100644 --- a/qiskit_experiments/library/mitigation/mitigation_analysis.py +++ b/qiskit_experiments/library/mitigation/mitigation_analysis.py @@ -20,7 +20,7 @@ from qiskit.result import marginal_counts from qiskit_experiments.framework import ExperimentData from qiskit_experiments.framework.matplotlib import get_non_gui_ax -from qiskit_experiments.framework import BaseAnalysis, AnalysisResultData, Options +from qiskit_experiments.framework import BaseAnalysis, AnalysisResult, Options class CorrelatedMitigationAnalysis(BaseAnalysis): @@ -30,13 +30,13 @@ class CorrelatedMitigationAnalysis(BaseAnalysis): def _run_analysis( self, experiment_data: ExperimentData, **options - ) -> Tuple[List[AnalysisResultData], List["matplotlib.figure.Figure"]]: + ) -> Tuple[List[AnalysisResult], List["matplotlib.figure.Figure"]]: data = experiment_data.data() qubits = experiment_data.metadata["physical_qubits"] labels = [datum["metadata"]["label"] for datum in data] matrix = self._generate_matrix(data, labels) result_mitigator = CorrelatedReadoutMitigator(matrix, qubits=qubits) - analysis_results = [AnalysisResultData("Correlated Readout Mitigator", result_mitigator)] + analysis_results = [AnalysisResult("Correlated Readout Mitigator", result_mitigator)] ax = options.get("ax", None) figures = [self._plot_calibration(matrix, labels, ax)] return analysis_results, figures @@ -106,12 +106,12 @@ def _default_options(cls) -> Options: def _run_analysis( self, experiment_data: ExperimentData - ) -> Tuple[List[AnalysisResultData], List["matplotlib.figure.Figure"]]: + ) -> Tuple[List[AnalysisResult], List["matplotlib.figure.Figure"]]: data = experiment_data.data() qubits = experiment_data.metadata["physical_qubits"] matrices = self._generate_matrices(data) result_mitigator = LocalReadoutMitigator(matrices, qubits=qubits) - analysis_results = [AnalysisResultData("Local Readout Mitigator", result_mitigator)] + analysis_results = [AnalysisResult("Local Readout Mitigator", result_mitigator)] if self.options.plot: figure = assignment_matrix_visualization( result_mitigator.assignment_matrix(), ax=self.options.ax diff --git a/qiskit_experiments/library/quantum_volume/qv_analysis.py b/qiskit_experiments/library/quantum_volume/qv_analysis.py index 753b601910..c37d2d7906 100644 --- a/qiskit_experiments/library/quantum_volume/qv_analysis.py +++ b/qiskit_experiments/library/quantum_volume/qv_analysis.py @@ -20,7 +20,7 @@ import numpy as np from uncertainties import ufloat -from qiskit_experiments.framework import BaseAnalysis, AnalysisResultData, Options +from qiskit_experiments.framework import BaseAnalysis, AnalysisResult, Options from qiskit_experiments.curve_analysis import plot_scatter, plot_errorbar @@ -205,7 +205,7 @@ def _calc_quantum_volume(self, heavy_output_prob_exp, depth, trials): quantum_volume = 2 ** depth success = True - hop_result = AnalysisResultData( + hop_result = AnalysisResult( "mean_HOP", value=ufloat(nominal_value=mean_hop, std_dev=sigma_hop), quality=quality, @@ -217,7 +217,7 @@ def _calc_quantum_volume(self, heavy_output_prob_exp, depth, trials): }, ) - qv_result = AnalysisResultData( + qv_result = AnalysisResult( "quantum_volume", value=quantum_volume, quality=quality, @@ -232,7 +232,7 @@ def _calc_quantum_volume(self, heavy_output_prob_exp, depth, trials): @staticmethod def _format_plot( - hop_result: AnalysisResultData, ax: Optional["matplotlib.pyplot.AxesSubplot"] = None + hop_result: AnalysisResult, ax: Optional["matplotlib.pyplot.AxesSubplot"] = None ): """Format the QV plot diff --git a/qiskit_experiments/library/randomized_benchmarking/interleaved_rb_analysis.py b/qiskit_experiments/library/randomized_benchmarking/interleaved_rb_analysis.py index 5fd4adff16..d21f91b866 100644 --- a/qiskit_experiments/library/randomized_benchmarking/interleaved_rb_analysis.py +++ b/qiskit_experiments/library/randomized_benchmarking/interleaved_rb_analysis.py @@ -16,7 +16,7 @@ import numpy as np import qiskit_experiments.curve_analysis as curve -from qiskit_experiments.framework import AnalysisResultData +from qiskit_experiments.framework import AnalysisResult from .rb_analysis import RBAnalysis @@ -161,7 +161,7 @@ def _generate_fit_guesses( return user_opt - def _extra_database_entry(self, fit_data: curve.FitData) -> List[AnalysisResultData]: + def _extra_database_entry(self, fit_data: curve.FitData) -> List[AnalysisResult]: """Calculate EPC.""" nrb = 2 ** self._num_qubits scale = (nrb - 1) / nrb @@ -183,7 +183,7 @@ def _extra_database_entry(self, fit_data: curve.FitData) -> List[AnalysisResultD systematic_err_l = epc.n - systematic_err systematic_err_r = epc.n + systematic_err - extra_data = AnalysisResultData( + extra_data = AnalysisResult( name="EPC", value=epc, chisq=fit_data.reduced_chisq, diff --git a/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py b/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py index 89acbdb736..39a6946b3c 100644 --- a/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py +++ b/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py @@ -18,8 +18,7 @@ import numpy as np import qiskit_experiments.curve_analysis as curve from qiskit_experiments.curve_analysis.data_processing import multi_mean_xy_data, data_sort -from qiskit_experiments.database_service.device_component import Qubit -from qiskit_experiments.framework import AnalysisResultData +from qiskit_experiments.framework import AnalysisResult from .rb_utils import RBUtils @@ -162,7 +161,7 @@ def _format_data(self, data: curve.CurveData) -> curve.CurveData: data_index=series, ) - def _extra_database_entry(self, fit_data: curve.FitData) -> List[AnalysisResultData]: + def _extra_database_entry(self, fit_data: curve.FitData) -> List[AnalysisResult]: """Calculate EPC.""" extra_entries = [] @@ -172,7 +171,7 @@ def _extra_database_entry(self, fit_data: curve.FitData) -> List[AnalysisResultD epc = scale * (1 - alpha) extra_entries.append( - AnalysisResultData( + AnalysisResult( name="EPC", value=epc, chisq=fit_data.reduced_chisq, @@ -224,12 +223,11 @@ def _extra_database_entry(self, fit_data: curve.FitData) -> List[AnalysisResultD for qubits, gate_dict in epg_dict.items(): for gate, value in gate_dict.items(): extra_entries.append( - AnalysisResultData( + AnalysisResult( f"EPG_{gate}", value, chisq=fit_data.reduced_chisq, quality=self._evaluate_quality(fit_data), - device_components=[Qubit(i) for i in qubits], ) ) return extra_entries diff --git a/qiskit_experiments/library/randomized_benchmarking/rb_utils.py b/qiskit_experiments/library/randomized_benchmarking/rb_utils.py index 4d5d3ebe42..3556ad7bfa 100644 --- a/qiskit_experiments/library/randomized_benchmarking/rb_utils.py +++ b/qiskit_experiments/library/randomized_benchmarking/rb_utils.py @@ -22,7 +22,7 @@ from uncertainties.core import UFloat from qiskit_experiments.database_service.device_component import Qubit -from qiskit_experiments.framework import DbAnalysisResultV1, AnalysisResultData +from qiskit_experiments.framework import DbAnalysisResultV1, AnalysisResult class RBUtils: @@ -223,7 +223,7 @@ def calculate_2q_epg( qubits: Sequence[int], gate_error_ratio: Dict[str, float], gates_per_clifford: Dict[Tuple[Sequence[int], str], float], - epg_1_qubit: Optional[List[Union[DbAnalysisResultV1, AnalysisResultData]]] = None, + epg_1_qubit: Optional[List[Union[DbAnalysisResultV1, AnalysisResult]]] = None, gate_2_qubit_type: Optional[str] = "cx", ) -> Dict[int, Dict[str, UFloat]]: r""" diff --git a/qiskit_experiments/library/tomography/tomography_analysis.py b/qiskit_experiments/library/tomography/tomography_analysis.py index f5d95c7bf1..e34ce9f87a 100644 --- a/qiskit_experiments/library/tomography/tomography_analysis.py +++ b/qiskit_experiments/library/tomography/tomography_analysis.py @@ -25,7 +25,7 @@ from qiskit.quantum_info.operators.channel.quantum_channel import QuantumChannel from qiskit_experiments.exceptions import AnalysisError -from qiskit_experiments.framework import BaseAnalysis, AnalysisResultData, Options +from qiskit_experiments.framework import BaseAnalysis, AnalysisResult, Options from .fitters import ( linear_inversion, scipy_linear_lstsq, @@ -179,7 +179,7 @@ def _postprocess_fit( rescaled_trace = True # Compute state with rescaled eigenvalues - state_result = AnalysisResultData("state", state, extra=metadata) + state_result = AnalysisResult("state", state, extra=metadata) state_result.extra["eigvals"] = scaled_evals if rescaled_psd or rescaled_trace: state = state_cls(evecs @ (scaled_evals * evecs).T.conj()) @@ -245,7 +245,7 @@ def _positivity_result(evals, qpt=False): cond = np.sum(np.abs(evals[evals < 0])) is_pos = bool(np.isclose(cond, 0)) name = "completely_positive" if qpt else "positive" - result = AnalysisResultData(name, is_pos) + result = AnalysisResult(name, is_pos) if not is_pos: result.extra = {"delta": cond} return result @@ -259,7 +259,7 @@ def _tp_result(evals, evecs): kraus_cond = np.einsum("i,ija,ijb->ab", evals, mats.conj(), mats) cond = np.sum(np.abs(la.eigvalsh(kraus_cond - np.eye(dim)))) is_tp = bool(np.isclose(cond, 0)) - result = AnalysisResultData("trace_preserving", is_tp) + result = AnalysisResult("trace_preserving", is_tp) if not is_tp: result.extra = {"delta": cond} return result @@ -286,7 +286,7 @@ def _fidelity_result(evals, evecs, target, qpt=False): sqrt_rho = evecs @ (np.sqrt(evals / trace) * evecs).T.conj() eig = la.eigvalsh(sqrt_rho @ target_state @ sqrt_rho) fidelity = np.sum(np.sqrt(np.maximum(eig, 0))) ** 2 - return AnalysisResultData(name, fidelity) + return AnalysisResult(name, fidelity) @staticmethod def _fitter_data( diff --git a/test/fake_experiment.py b/test/fake_experiment.py index 0430553cb4..6c252a177d 100644 --- a/test/fake_experiment.py +++ b/test/fake_experiment.py @@ -13,7 +13,7 @@ """A FakeExperiment for testing.""" import numpy as np -from qiskit_experiments.framework import BaseExperiment, BaseAnalysis, Options, AnalysisResultData +from qiskit_experiments.framework import BaseExperiment, BaseAnalysis, Options, AnalysisResult class FakeAnalysis(BaseAnalysis): @@ -29,7 +29,7 @@ def _run_analysis(self, experiment_data, **options): seed = options.get("seed", None) rng = np.random.default_rng(seed=seed) analysis_results = [ - AnalysisResultData(f"result_{i}", value) for i, value in enumerate(rng.random(3)) + AnalysisResult(f"result_{i}", value) for i, value in enumerate(rng.random(3)) ] return analysis_results, None diff --git a/test/fake_service.py b/test/fake_service.py index cce499077d..13e662a078 100644 --- a/test/fake_service.py +++ b/test/fake_service.py @@ -12,6 +12,7 @@ """Fake service class for tests.""" +from collections import defaultdict from typing import Optional, List, Dict, Type, Any, Union, Tuple import copy import json @@ -30,7 +31,9 @@ class FakeService(DatabaseServiceV1): """ def __init__(self): - self.database = {} + self.experiment_data = dict() + self.analysis_data = dict() + self.id_map = defaultdict(list) def create_experiment( self, @@ -66,7 +69,7 @@ def create_experiment( Experiment ID. """ - self.database[experiment_id] = { + self.experiment_data[experiment_id] = { "experiment_type": experiment_type, "experiment_id": experiment_id, "parent_id": parent_id, @@ -116,7 +119,7 @@ def experiment( A dictionary containing the retrieved experiment data. """ - db_entry = copy.deepcopy(self.database[experiment_id]) + db_entry = copy.deepcopy(self.experiment_data[experiment_id]) db_entry["backend"] = FakeBackend() return db_entry @@ -150,7 +153,7 @@ def create_analysis_result( json_encoder: Type[json.JSONEncoder] = json.JSONEncoder, **kwargs: Any, ) -> str: - self.database[experiment_id]["analysis"][result_id] = { + data_dict = { "result_data": result_data, "result_id": result_id, "result_type": result_type, @@ -161,6 +164,9 @@ def create_analysis_result( "tags": tags, "service": self, } + data_json = json.dumps(data_dict, cls=json_encoder) + self.analysis_data[result_id] = data_json + self.id_map[experiment_id].append(result_id) return result_id @@ -178,7 +184,7 @@ def update_analysis_result( def analysis_result( self, result_id: str, json_decoder: Type[json.JSONDecoder] = json.JSONDecoder ) -> Dict: - raise Exception("not implemented") + return json.loads(self.analysis_data[result_id], cls=json_decoder) def analysis_results( self, @@ -194,7 +200,12 @@ def analysis_results( tags_operator: Optional[str] = "OR", **filters: Any, ) -> List[Dict]: - return self.database[experiment_id]["analysis"].values() + # this only implements filtering for experiment id which is minimum requirement for test + results = [] + for res_id in self.id_map[experiment_id]: + results.append(self.analysis_result(res_id, json_decoder)) + + return results def delete_analysis_result(self, result_id: str) -> None: raise Exception("not implemented") diff --git a/test/randomized_benchmarking/test_rb_utils.py b/test/randomized_benchmarking/test_rb_utils.py index d1d85832ca..4f0fa1e781 100644 --- a/test/randomized_benchmarking/test_rb_utils.py +++ b/test/randomized_benchmarking/test_rb_utils.py @@ -33,7 +33,7 @@ ) from qiskit.quantum_info import Clifford import qiskit_experiments.library.randomized_benchmarking as rb -from qiskit_experiments.framework import AnalysisResultData +from qiskit_experiments.framework import AnalysisResult @ddt @@ -136,12 +136,12 @@ def test_calculate_2q_epg(self): ((1,), "x"): 0.2525918944392083, } epg_1_qubit = [ - AnalysisResultData("EPG_rz", 0.0, device_components=[1]), - AnalysisResultData("EPG_rz", 0.0, device_components=[4]), - AnalysisResultData("EPG_sx", 0.00036207066403884814, device_components=[1]), - AnalysisResultData("EPG_sx", 0.0005429962529239195, device_components=[4]), - AnalysisResultData("EPG_x", 0.00036207066403884814, device_components=[1]), - AnalysisResultData("EPG_x", 0.0005429962529239195, device_components=[4]), + AnalysisResult("EPG_rz", 0.0, device_components=[1]), + AnalysisResult("EPG_rz", 0.0, device_components=[4]), + AnalysisResult("EPG_sx", 0.00036207066403884814, device_components=[1]), + AnalysisResult("EPG_sx", 0.0005429962529239195, device_components=[4]), + AnalysisResult("EPG_x", 0.00036207066403884814, device_components=[1]), + AnalysisResult("EPG_x", 0.0005429962529239195, device_components=[4]), ] epg = rb.RBUtils.calculate_2q_epg( epc_2_qubit, qubits, gate_error_ratio, gates_per_clifford, epg_1_qubit diff --git a/test/test_framework.py b/test/test_framework.py index 1b1f177ee0..458cd04197 100644 --- a/test/test_framework.py +++ b/test/test_framework.py @@ -13,12 +13,15 @@ """Tests for base experiment framework.""" from test.fake_backend import FakeBackend +from test.fake_service import FakeService from test.fake_experiment import FakeExperiment, FakeAnalysis from test.base import QiskitExperimentsTestCase import ddt from qiskit import QuantumCircuit -from qiskit_experiments.framework import ExperimentData +from qiskit_experiments.framework import ExperimentData, AnalysisResult +from qiskit_experiments.database_service.exceptions import DbExperimentDataError +from uncertainties import ufloat @ddt.ddt @@ -105,3 +108,148 @@ def test_analysis_runtime_opts(self): analysis.set_options(**opts) analysis.run(ExperimentData(), **run_opts) self.assertEqual(analysis.options.__dict__, opts) + + +class TestAnalysisResult(QiskitExperimentsTestCase): + + def test_cannot_save_result(self): + """Analysis result cannot be saved without id or device info.""" + result = AnalysisResult(name="fake", value=123) + + # ID or device components are not populated + with self.assertRaises(DbExperimentDataError): + result.save() + + def test_round_trip_result(self): + """AnalysisResult can be saved and loaded from database.""" + service = FakeService() + + result = AnalysisResult(name="test", value=123) + result.experiment_id = "12345" + result.device_components = ["Q1", "Q2"] + result.service = service + result.save() + + loaded_result = AnalysisResult.load(result_id=result.result_id, service=service) + + self.assertEqual(repr(result), repr(loaded_result)) + + def test_round_trip_ufloat(self): + """UFloat value can be stored in result and saved and loaded from database.""" + service = FakeService() + + result = AnalysisResult(name="test", value=ufloat(0.1, 0.2)) + result.experiment_id = "12345" + result.device_components = ["Q1"] + result.service = service + result.save() + + loaded_result = AnalysisResult.load(result_id=result.result_id, service=service) + + self.assertEqual(repr(result), repr(loaded_result)) + + def test_round_trip_ufloat_with_unit(self): + """UFloat value with unit can be stored in result and saved and loaded from database.""" + service = FakeService() + + result = AnalysisResult(name="test", value=ufloat(0.1, 0.2), unit="Hz") + result.experiment_id = "12345" + result.device_components = ["Q1"] + result.service = service + result.save() + + loaded_result = AnalysisResult.load(result_id=result.result_id, service=service) + + self.assertEqual(repr(result), repr(loaded_result)) + + def test_round_trip_ufloat_operable(self): + """Loaded UFloat values can be operated with variance.""" + service = FakeService() + + result1 = AnalysisResult(name="test", value=ufloat(12.1, 3.0)) + result1.experiment_id = "12345" + result1.device_components = ["Q1"] + result1.service = service + result1.save() + + result2 = AnalysisResult(name="test", value=ufloat(15.6, 4.0)) + result2.experiment_id = "12345" + result2.device_components = ["Q1"] + result2.service = service + result2.save() + + loaded_result1 = AnalysisResult.load(result_id=result1.result_id, service=service) + loaded_result2 = AnalysisResult.load(result_id=result2.result_id, service=service) + + new_val = loaded_result1.value + loaded_result2.value + + self.assertEqual(new_val.n, 27.7) + self.assertEqual(new_val.s, 5.0) + + def test_expdata_ufloat_operable(self): + """UFloat values saved in the experiment data can be operated with variance.""" + expdata = ExperimentData() + + result1 = AnalysisResult(name="test1", value=ufloat(12.1, 3.0)) + result1.experiment_id = "12345" + result1.device_components = ["Q1"] + + result2 = AnalysisResult(name="test2", value=ufloat(15.6, 4.0)) + result2.experiment_id = "12345" + result2.device_components = ["Q1"] + + expdata.add_analysis_results([result1, result2]) + + loaded_result1 = expdata.analysis_results("test1") + loaded_result2 = expdata.analysis_results("test2") + + new_val = loaded_result1.value + loaded_result2.value + + self.assertEqual(new_val.n, 27.7) + self.assertEqual(new_val.s, 5.0) + + def test_round_trip_expdata_analysis(self): + """Test round trip analysis result via experiment data.""" + service = FakeService() + + expdata = ExperimentData(backend=FakeBackend()) + expdata.service = service + + result = AnalysisResult(name="test", value="some_value") + result.experiment_id = expdata.experiment_id + result.device_components = ["Q1"] + + expdata.add_analysis_results(result) + expdata.save() + + loaded_expdata = ExperimentData.load(experiment_id=expdata.experiment_id, service=service) + loaded_result = loaded_expdata.analysis_results("test") + + self.assertEqual(repr(result), repr(loaded_result)) + + def test_round_trip_composit_expdata_analysis(self): + """Test round trip analysis result via composite experiment data.""" + service = FakeService() + + expdata1 = ExperimentData(backend=FakeBackend()) + result1 = AnalysisResult(name="test1", value="foo", quality="good", extra={"meta1": 1}) + result1.experiment_id = expdata1.experiment_id + result1.device_components = ["Q1"] + expdata1.add_analysis_results(result1) + + expdata2 = ExperimentData(backend=FakeBackend()) + result2 = AnalysisResult(name="test2", value="boo", quality="bad", extra={"meta2": 2}) + result2.experiment_id = expdata2.experiment_id + result2.device_components = ["Q2"] + expdata2.add_analysis_results(result2) + + comp_expdata = ExperimentData(backend=FakeBackend(), child_data=[expdata1, expdata2]) + comp_expdata.service = service + comp_expdata.save() + + loaded_expdata = ExperimentData.load(experiment_id=comp_expdata.experiment_id, service=service) + loaded_result1 = loaded_expdata.child_data(0).analysis_results("test1") + loaded_result2 = loaded_expdata.child_data(1).analysis_results("test2") + + self.assertEqual(repr(result1), repr(loaded_result1)) + self.assertEqual(repr(result2), repr(loaded_result2))