From 4b820d24049a2f3fa914ac3d9cce47e66519dd46 Mon Sep 17 00:00:00 2001 From: Will Shanks Date: Tue, 10 Feb 2026 15:48:54 -0500 Subject: [PATCH] Adopt database classes and local experiment service from qiskit-ibm-experiment * Remove the `qiskit-ibm-experiment` dependency + Adopt `DbExperimentData`, `DbAnalysisResultData`, and `ResultQuality` + Adapt `IBMExperimentService` into `LocalExperimentService` which only maintains the local features of the original class. + Remove code for inferring service from backend or provider * Revise the experiment service howto and other comments to refer to the `LocalExperimentService` and not to the retired cloud experiment service. * Add optional pyyaml dependency * Drop IBMProvider protocol * Update `ExperimentData` and `AnalysisResult` not to try to work with the old IBM experiment service. * Remove `FakeService` test class and use `LocalExperimentService` in tests instead. + Additionally, remove all usage of mocking from the tests, using `LocalExperimentService` in place of a mocked service. --- docs/howtos/artifacts.rst | 19 +- docs/howtos/cloud_service.rst | 150 --- .../experiment_cloud_service/t1_loaded.png | Bin 45677 -> 0 bytes docs/howtos/experiment_service.rst | 160 +++ docs/howtos/experiment_times.rst | 11 - docs/howtos/rerun_analysis.rst | 7 +- docs/release_notes.rst | 6 +- pyproject.toml | 6 +- .../curve_analysis/scatter_table.py | 10 +- .../database_service/__init__.py | 37 +- .../database_service/constants.py | 41 + .../db_analysis_result_data.py | 89 ++ .../database_service/db_experiment_data.py | 98 ++ .../local_experiment_service.py | 1112 +++++++++++++++++ qiskit_experiments/database_service/utils.py | 13 +- qiskit_experiments/framework/__init__.py | 4 +- .../framework/analysis_result.py | 36 +- .../framework/analysis_result_data.py | 4 +- .../framework/containers/artifact_data.py | 2 +- .../framework/experiment_data.py | 246 ++-- .../framework/provider_interfaces.py | 316 ++++- qiskit_experiments/test/__init__.py | 1 - qiskit_experiments/test/fake_service.py | 494 -------- qiskit_experiments/test/utils.py | 51 +- ...iskit-ibm-experiment-9bc997dd31535ffc.yaml | 34 + .../notes/lazy-imports-730268f4cd8763dc.yaml | 2 +- .../test_db_analysis_result.py | 73 +- .../test_db_experiment_data.py | 300 ++--- test/database_service/test_fake_service.py | 448 ------- .../test_local_experiment_service.py | 357 ++++++ test/extended_equality.py | 12 +- test/fake_experiment.py | 14 +- test/framework/test_composite.py | 16 +- test/framework/test_framework.py | 42 +- .../characterization/test_readout_error.py | 4 +- 35 files changed, 2620 insertions(+), 1595 deletions(-) delete mode 100644 docs/howtos/cloud_service.rst delete mode 100644 docs/howtos/experiment_cloud_service/t1_loaded.png create mode 100644 docs/howtos/experiment_service.rst create mode 100644 qiskit_experiments/database_service/constants.py create mode 100644 qiskit_experiments/database_service/db_analysis_result_data.py create mode 100644 qiskit_experiments/database_service/db_experiment_data.py create mode 100644 qiskit_experiments/database_service/local_experiment_service.py delete mode 100644 qiskit_experiments/test/fake_service.py create mode 100644 releasenotes/notes/adopt-qiskit-ibm-experiment-9bc997dd31535ffc.yaml delete mode 100644 test/database_service/test_fake_service.py create mode 100644 test/database_service/test_local_experiment_service.py diff --git a/docs/howtos/artifacts.rst b/docs/howtos/artifacts.rst index b3075507f4..ea22ffcb6f 100644 --- a/docs/howtos/artifacts.rst +++ b/docs/howtos/artifacts.rst @@ -106,13 +106,8 @@ Qiskit Experiments. Saving and loading artifacts ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. note:: - This feature is only for those who have access to the cloud service. You can - check whether you do by logging into the IBM Quantum interface - and seeing if you can see the `database `__. - -Artifacts are saved and loaded to and from the cloud service along with the rest of the -:class:`ExperimentData` object. Artifacts are stored as ``.zip`` files in the cloud service grouped by +Artifacts are saved and loaded to and from an experiment service along with the rest of the +:class:`ExperimentData` object. Artifacts are stored as ``.zip`` files in the service grouped by the artifact name. For example, the composite experiment above will generate two artifact files, ``fit_summary.zip`` and ``curve_data.zip``. Each of these zipfiles will contain serialized artifact data in JSON format named by their unique artifact ID: @@ -130,14 +125,8 @@ by their unique artifact ID: print(f"|- {data.artifacts('experiment_notes').artifact_id}.json") Note that for performance reasons, the auto save feature does not apply to artifacts. You must still -call :meth:`.ExperimentData.save` once the experiment analysis has completed to upload artifacts to the -cloud service. - -Note also though individual artifacts can be deleted, currently artifact files cannot be removed from the -cloud service. Instead, you can delete all artifacts of that name -using :meth:`~.delete_artifact` and then call :meth:`.ExperimentData.save`. -This will save an empty file to the service, and the loaded experiment data will not contain -these artifacts. +call :meth:`.ExperimentData.save` once the experiment analysis has completed to save artifacts in the +service. See Also -------- diff --git a/docs/howtos/cloud_service.rst b/docs/howtos/cloud_service.rst deleted file mode 100644 index a26709cb5f..0000000000 --- a/docs/howtos/cloud_service.rst +++ /dev/null @@ -1,150 +0,0 @@ -Save and load experiment data with the cloud service -==================================================== - -.. note:: - The cloud service at `database ` has been - sunset in the move to the new IBM Quantum cloud platform. Saving and loading - to the cloud will not work. We are working on a local saving solution. - -Problem -------- - -You want to save and retrieve experiment data from the cloud service. - -Solution --------- - -Saving -~~~~~~ - -.. note:: - This guide requires :external+qiskit_ibm_runtime:doc:`qiskit-ibm-runtime ` version 0.15 and up, which can be installed with ``python -m pip install qiskit-ibm-runtime``. - For how to migrate from the older ``qiskit-ibm-provider`` to :external+qiskit_ibm_runtime:doc:`qiskit-ibm-runtime `, - consult the `migration guide `_.\ - -You must run the experiment on a real IBM -backend and not a simulator to be able to save the experiment data. This is done by calling -:meth:`~.ExperimentData.save`: - -.. jupyter-input:: - - from qiskit_ibm_runtime import QiskitRuntimeService - from qiskit_experiments.library.characterization import T1 - import numpy as np - - service = QiskitRuntimeService(channel="ibm_quantum") - backend = service.backend("ibm_osaka") - - t1_delays = np.arange(1e-6, 600e-6, 50e-6) - - exp = T1(physical_qubits=(0,), delays=t1_delays) - - t1_expdata = exp.run(backend=backend).block_for_results() - t1_expdata.save() - -.. jupyter-output:: - - You can view the experiment online at - https://quantum.ibm.com/experiments/10a43cb0-7cb9-41db-ad74-18ea6cf63704 - -Loading -~~~~~~~ - -Let's load a `previous T1 -experiment `__ -(requires login to view), which we've made public by editing the ``Share level`` field: - -.. jupyter-input:: - - from qiskit_experiments.framework import ExperimentData - load_expdata = ExperimentData.load("9640736e-d797-4321-b063-d503f8e98571", provider=service) - -Now we can display the figure from the loaded experiment data: - -.. jupyter-input:: - - load_expdata.figure(0) - -.. image:: ./experiment_cloud_service/t1_loaded.png - -The analysis results have been retrieved as well and can be accessed normally. - -.. jupyter-input:: - - results = load_expdata.analysis_results(dataframe=True)) - -Discussion ----------- - -Note that calling :meth:`~.ExperimentData.save` before the experiment is complete will -instantiate an experiment entry in the database, but it will not have -complete data. To fix this, you can call :meth:`~.ExperimentData.save` again once the -experiment is done running. - -Sometimes the metadata of an experiment can be very large and cannot be stored directly in the database. -In this case, a separate ``metadata.json`` file will be stored along with the experiment. Saving and loading -this file is done automatically in :meth:`~.ExperimentData.save` and :meth:`~.ExperimentData.load`. - -Auto-saving an experiment -~~~~~~~~~~~~~~~~~~~~~~~~~ - -The :meth:`~.ExperimentData.auto_save` feature automatically saves changes to the -:class:`.ExperimentData` object to the cloud service whenever it's updated. - -.. jupyter-input:: - - exp = T1(physical_qubits=(0,), delays=t1_delays) - - t1_expdata = exp.run(backend=backend, shots=1000) - t1_expdata.auto_save = True - t1_expdata.block_for_results() - -.. jupyter-output:: - - You can view the experiment online at https://quantum.ibm.com/experiments/cdaff3fa-f621-4915-a4d8-812d05d9a9ca - - -Setting ``auto_save = True`` works by triggering :meth:`.ExperimentData.save`. - -When working with composite experiments, setting ``auto_save`` will propagate this -setting to the child experiments. - -Deleting an experiment -~~~~~~~~~~~~~~~~~~~~~~ - -Both figures and analysis results can be deleted. Note that unless you -have auto save on, the update has to be manually saved to the remote -database by calling :meth:`~.ExperimentData.save`. Because there are two analysis -results, one for the T1 parameter and one for the curve fitting results, we must -delete twice to fully remove the analysis results. - -.. jupyter-input:: - - t1_expdata.delete_figure(0) - t1_expdata.delete_analysis_result(0) - t1_expdata.delete_analysis_result(0) - -.. jupyter-output:: - - Are you sure you want to delete the experiment plot? [y/N]: y - Are you sure you want to delete the analysis result? [y/N]: y - Are you sure you want to delete the analysis result? [y/N]: y - -Tagging and sharing experiments -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Tags and notes can be added to experiments to help identify specific experiments in the interface. -For example, an experiment can be tagged and made public with the following code. - -.. jupyter-input:: - - t1_expdata.tags = ['tag1', 'tag2'] - t1_expdata.share_level = "public" - t1_expdata.notes = "Example note." - -Web interface -~~~~~~~~~~~~~ - -You can also view experiment results as well as change the tags and share level at the `IBM Quantum Experiments -pane `__ -on the cloud. diff --git a/docs/howtos/experiment_cloud_service/t1_loaded.png b/docs/howtos/experiment_cloud_service/t1_loaded.png deleted file mode 100644 index a20f738ab3de10ba7a8b12036062df0e5240f1c4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45677 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. - -- :attr:`.ExperimentData.creation_datetime` is the time when the experiment data was saved via the - service. This defaults to ``None`` if experiment data has not yet been saved. - -- :attr:`.ExperimentData.updated_datetime` is the time the experiment data entry in the service was - last updated. This defaults to ``None`` if experiment data has not yet been saved. - Discussion ---------- diff --git a/docs/howtos/rerun_analysis.rst b/docs/howtos/rerun_analysis.rst index 0b6b9c4496..d1bcd3be5c 100644 --- a/docs/howtos/rerun_analysis.rst +++ b/docs/howtos/rerun_analysis.rst @@ -11,11 +11,6 @@ execution successfully. Solution -------- -.. note:: - This guide requires :external+qiskit_ibm_runtime:doc:`qiskit-ibm-runtime ` version 0.15 and up, which can be installed with ``python -m pip install qiskit-ibm-runtime``. - For how to migrate from the older ``qiskit-ibm-provider`` to :external+qiskit_ibm_runtime:doc:`qiskit-ibm-runtime `, - consult the `migration guide `_.\ - Once you recreate the exact experiment you ran and all of its parameters and options, you can call the :meth:`.ExperimentData.add_jobs` method with a list of :class:`Job ` objects to generate the new :class:`.ExperimentData` object. @@ -122,4 +117,4 @@ first component experiment. See Also -------- -* `Saving and loading experiment data with the cloud service `_ +* `Saving and loading experiment data with an experiment service `_ diff --git a/docs/release_notes.rst b/docs/release_notes.rst index 1c91354c00..3b920e6b68 100644 --- a/docs/release_notes.rst +++ b/docs/release_notes.rst @@ -708,7 +708,7 @@ API Changes for Experiment Authors :class:`~qiskit_experiments.framework.ExtendedJob`, :class:`~qiskit_experiments.framework.Job`, :class:`~qiskit_experiments.framework.BaseProvider`, - :class:`~qiskit_experiments.framework.IBMProvider`, and + ``qiskit_experiments.framework.IBMProvider``, and :class:`~qiskit_experiments.framework.Provider` to document the interfaces needed by :class:`~.ExperimentData` to work with jobs and results. @@ -1522,11 +1522,11 @@ Deprecation Notes .. releasenotes/notes/0.6/experiment-artifacts-c481f4e07226ce9e.yaml @ b'e8531c4f6af9432827bc28c772c5a179737f0c3c' - Direct access to the curve fit summary in :class:`.ExperimentData` has moved from - :meth:`.analysis_results` to :meth:`.artifacts`, where values are stored in the + :meth:`.ExperimentDat.analysis_results` to :meth:`.artifacts`, where values are stored in the :attr:`~.ArtifactData.data` attribute of :class:`.ArtifactData` objects. For example, to access the chi-squared of the fit, ``expdata.analysis_results(0).chisq`` is deprecated in favor of ``expdata.artifacts("fit_summary").data.chisq``. In a future release, the curve fit summary - will be removed from :meth:`.analysis_results` and the option ``return_fit_parameters`` will be + will be removed from :meth:`.ExperimentData.analysis_results` and the option ``return_fit_parameters`` will be removed. For more information on artifacts, see the :doc:`artifacts how-to `. .. releasenotes/notes/0.6/experiment-artifacts-c481f4e07226ce9e.yaml @ b'e8531c4f6af9432827bc28c772c5a179737f0c3c' diff --git a/pyproject.toml b/pyproject.toml index b6041dbbad..ae0a4e0fab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,7 @@ dependencies = [ "numpy>=1.17", "scipy>=1.4", "qiskit>=1.3", - "qiskit-ibm-experiment>=0.4.6", - "qiskit_ibm_runtime>=0.34.0", + "qiskit-ibm-runtime>=0.34.0", "matplotlib>=3.4", "uncertainties", "lmfit", @@ -51,8 +50,9 @@ Documentation = "https://qiskit-community.github.io/qiskit-experiments" [project.optional-dependencies] extras = [ "cvxpy>=1.3.2", # for tomography + "pyyaml>=6.0.0", # for storing yaml files in local experiment service + "qiskit-aer>=0.13.2", # for simulating QV circuits or using test backends "scikit-learn", # for discriminators - "qiskit-aer>=0.13.2", ] [project.entry-points."qiskit.synthesis"] diff --git a/qiskit_experiments/curve_analysis/scatter_table.py b/qiskit_experiments/curve_analysis/scatter_table.py index 78df3b496f..47daca7807 100644 --- a/qiskit_experiments/curve_analysis/scatter_table.py +++ b/qiskit_experiments/curve_analysis/scatter_table.py @@ -81,7 +81,7 @@ def __init__(self): def from_dataframe( cls, data: pd.DataFrame, - ) -> "ScatterTable": + ) -> ScatterTable: """Create new dataset with existing dataframe. Args: @@ -99,7 +99,7 @@ def from_dataframe( def _create_new_instance( cls, data: pd.DataFrame, - ) -> "ScatterTable": + ) -> ScatterTable: # A shortcut for creating instance. # This bypasses data formatting and column compatibility check. # User who calls this method must guarantee the quality of the input data. @@ -312,7 +312,7 @@ def filter( filt_data = filt_data.loc[index, :] return ScatterTable._create_new_instance(filt_data) - def iter_by_series_id(self) -> Iterator[tuple[int, "ScatterTable"]]: + def iter_by_series_id(self) -> Iterator[tuple[int, ScatterTable]]: """Iterate over subset of data sorted by the data series index. Yields: @@ -325,7 +325,7 @@ def iter_by_series_id(self) -> Iterator[tuple[int, "ScatterTable"]]: def iter_groups( self, *group_by: str, - ) -> Iterator[tuple[tuple[Any, ...], "ScatterTable"]]: + ) -> Iterator[tuple[tuple[Any, ...], ScatterTable]]: """Iterate over the subset sorted by multiple column values. Args: @@ -420,7 +420,7 @@ def __json_encode__(self) -> dict[str, Any]: } @classmethod - def __json_decode__(cls, value: dict[str, Any]) -> "ScatterTable": + def __json_decode__(cls, value: dict[str, Any]) -> ScatterTable: if not value.get("class", None) == "ScatterTable": raise ValueError("JSON decoded value for ScatterTable is not valid class type.") tmp_df = pd.DataFrame.from_dict(value.get("data", {}), orient="index") diff --git a/qiskit_experiments/database_service/__init__.py b/qiskit_experiments/database_service/__init__.py index b3cd7eaa1d..b6d73f4948 100644 --- a/qiskit_experiments/database_service/__init__.py +++ b/qiskit_experiments/database_service/__init__.py @@ -32,6 +32,31 @@ UnknownComponent to_component +Dataclasses +=========== + +.. autosummary:: + :toctree: ../stubs/ + + DbExperimentData + DbAnalysisResultData + +Constants +========= + +.. autosummary:: + :toctree: ../stubs/ + + ResultQuality + +Services +======== + +.. autosummary:: + :toctree: ../stubs/ + + LocalExperimentService + Exceptions ========== @@ -39,9 +64,19 @@ :toctree: ../stubs/ ExperimentDataError + ExperimentDataSaveFailed ExperimentEntryExists ExperimentEntryNotFound """ -from .exceptions import ExperimentDataError, ExperimentEntryExists, ExperimentEntryNotFound +from .exceptions import ( + ExperimentDataError, + ExperimentDataSaveFailed, + ExperimentEntryExists, + ExperimentEntryNotFound, +) from .device_component import DeviceComponent, Qubit, Resonator, UnknownComponent, to_component +from .constants import ResultQuality +from .db_experiment_data import DbExperimentData +from .db_analysis_result_data import DbAnalysisResultData +from .local_experiment_service import LocalExperimentService diff --git a/qiskit_experiments/database_service/constants.py b/qiskit_experiments/database_service/constants.py new file mode 100644 index 0000000000..e3c7868d0f --- /dev/null +++ b/qiskit_experiments/database_service/constants.py @@ -0,0 +1,41 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021, 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Experiment constants.""" + +from __future__ import annotations + +import enum + + +class ResultQuality(enum.Enum): + """Possible values for analysis result quality.""" + + BAD = "bad" + GOOD = "good" + UNKNOWN = "unknown" + + @staticmethod + def from_str(quality: str) -> ResultQuality: + """Convert quality to a ResultQuality, defaulting to UNKNOWN""" + try: + result = ResultQuality(str(quality).lower()) + except ValueError: + result = ResultQuality.UNKNOWN + return result + + @staticmethod + def to_str(quality: ResultQuality) -> str: + """Convert quality to string, defaulting to "unknown" """ + if isinstance(quality, ResultQuality): + return quality.value + return "unknown" diff --git a/qiskit_experiments/database_service/db_analysis_result_data.py b/qiskit_experiments/database_service/db_analysis_result_data.py new file mode 100644 index 0000000000..f8fddcc25b --- /dev/null +++ b/qiskit_experiments/database_service/db_analysis_result_data.py @@ -0,0 +1,89 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Dataclass for analysis result data in the database""" +import copy +import uuid + +from dataclasses import dataclass, field +from typing import Any +from datetime import datetime + +from .constants import ResultQuality +from .device_component import DeviceComponent + + +@dataclass +class DbAnalysisResultData: + """Dataclass for experiment analysis results in the database. + + .. note:: + + The documentation does not currently render all the fields of this + dataclass. + + .. note:: + + This class is named DbAnalysisResultData to avoid confusion with the + :class:`~qiskit_experiments.framework.AnalysisResult` class. + """ + + result_id: str | None = field(default_factory=lambda: str(uuid.uuid4())) + experiment_id: str | None = None + result_type: str | None = None + result_data: dict[str, Any] | None = field(default_factory=dict) + device_components: list[str | DeviceComponent] | str | DeviceComponent | None = field( + default_factory=list + ) + quality: ResultQuality | None = ResultQuality.UNKNOWN + verified: bool | None = False + tags: list[str] | None = field(default_factory=list) + backend_name: str | None = None + creation_datetime: datetime | None = None + updated_datetime: datetime | None = None + chisq: float | None = None + + def __str__(self): + ret = f"Result {self.result_type}" + ret += f"\nResult ID: {self.result_id}" + ret += f"\nExperiment ID: {self.experiment_id}" + ret += f"\nBackend: {self.backend_name}" + ret += f"\nQuality: {self.quality}" + ret += f"\nVerified: {self.verified}" + ret += f"\nDevice components: {self.device_components}" + ret += f"\nData: {self.result_data}" + if self.chisq: + ret += f"\nChi Square: {self.chisq}" + if self.tags: + ret += f"\nTags: {self.tags}" + if self.creation_datetime: + ret += f"\nCreated at: {self.creation_datetime}" + if self.updated_datetime: + ret += f"\nUpdated at: {self.updated_datetime}" + return ret + + def copy(self): + """Creates a deep copy of the data""" + return DbAnalysisResultData( + result_id=self.result_id, + experiment_id=self.experiment_id, + result_type=self.result_type, + result_data=copy.deepcopy(self.result_data), + device_components=copy.copy(self.device_components), + quality=self.quality, + verified=self.verified, + tags=copy.copy(self.tags), + backend_name=self.backend_name, + creation_datetime=self.creation_datetime, + updated_datetime=self.updated_datetime, + chisq=self.chisq, + ) diff --git a/qiskit_experiments/database_service/db_experiment_data.py b/qiskit_experiments/database_service/db_experiment_data.py new file mode 100644 index 0000000000..99d00c4da3 --- /dev/null +++ b/qiskit_experiments/database_service/db_experiment_data.py @@ -0,0 +1,98 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Dataclass for experiment data in the database""" +import copy +import uuid +from dataclasses import dataclass, field +from datetime import datetime + + +@dataclass +class DbExperimentData: + """Dataclass for experiments in the database. + + .. note:: + + The documentation does not currently render all the fields of this + dataclass. + + .. note:: + + This is named DbExperimentData to avoid confusion with the main + :class:`~qiskit_experiments.framework.ExperimentData` class. + """ + + experiment_id: str = field(default_factory=lambda: str(uuid.uuid4())) + parent_id: str | None = None + experiment_type: str | None = None + backend: str | None = None + tags: list[str] | None = field(default_factory=list) + job_ids: list[str] | None = field(default_factory=list) + share_level: str | None = None + metadata: dict[str, str] | None = field(default_factory=dict) + figure_names: list[str] | None = field(default_factory=list) + notes: str | None = None + hub: str | None = None + group: str | None = None + project: str | None = None + owner: str | None = None + creation_datetime: datetime | None = None + start_datetime: datetime | None = None + end_datetime: datetime | None = None + updated_datetime: datetime | None = None + + def __str__(self): + ret = "" + ret += f"Experiment: {self.experiment_type}" + ret += f"\nExperiment ID: {self.experiment_id}" + if self.backend: + ret += f"\nBackend: {self.backend}" + if self.tags: + ret += f"\nTags: {self.tags}" + ret += f"\nHub\\Group\\Project: {self.hub}\\{self.group}\\{self.project}" + if self.creation_datetime: + ret += f"\nCreated at: {self.creation_datetime}" + if self.start_datetime: + ret += f"\nStarted at: {self.start_datetime}" + if self.end_datetime: + ret += f"\nEnded at: {self.end_datetime}" + if self.updated_datetime: + ret += f"\nUpdated at: {self.updated_datetime}" + if self.metadata: + ret += f"\nMetadata: {self.metadata}" + if self.figure_names: + ret += f"\nFigures: {self.figure_names}" + return ret + + def copy(self): + """Creates a deep copy of the data""" + return DbExperimentData( + experiment_id=self.experiment_id, + parent_id=self.parent_id, + experiment_type=self.experiment_type, + backend=self.backend, + tags=copy.copy(self.tags), + job_ids=copy.copy(self.job_ids), + share_level=self.share_level, + metadata=copy.deepcopy(self.metadata), + figure_names=copy.copy(self.figure_names), + notes=self.notes, + hub=self.hub, + group=self.group, + project=self.project, + owner=self.owner, + creation_datetime=self.creation_datetime, + start_datetime=self.start_datetime, + end_datetime=self.end_datetime, + updated_datetime=self.updated_datetime, + ) diff --git a/qiskit_experiments/database_service/local_experiment_service.py b/qiskit_experiments/database_service/local_experiment_service.py new file mode 100644 index 0000000000..7b6e9757ef --- /dev/null +++ b/qiskit_experiments/database_service/local_experiment_service.py @@ -0,0 +1,1112 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021, 2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Local experiment service for storing experiment data locally.""" + +import json +import logging +import os +from dataclasses import fields +from datetime import datetime, timezone + +import pandas as pd +import numpy as np + +from qiskit_experiments.database_service import ( + DbAnalysisResultData, + DbExperimentData, + ExperimentEntryNotFound, + ExperimentEntryExists, + ResultQuality, +) + +logger = logging.getLogger(__name__) + + +class LocalExperimentService: + """Provides local experiment database services. + + This class provides a service for storing experiment data locally + without connecting to a remote service. Data can be persisted to + disk or kept only in memory. + + .. note:: + + This class is designed for demonstration and testing purposes and will + not scale well to storing many results. It stores all results in memory + and writes all data out to disk at every save. It could serve as a + reference for writing a more scalable system for saving experiments. + """ + + experiment_db_columns = [f.name for f in fields(DbExperimentData)] + results_db_columns = [f.name for f in fields(DbAnalysisResultData)] + + def __init__( + self, + db_dir: str | None = None, + ) -> None: + """LocalExperimentService constructor. + + Args: + db_dir: The directory in which to place the database files. + If None, results are saved in memory only and lost when the + Python process ends. + """ + self._experiments = pd.DataFrame() + self._results = pd.DataFrame() + self._figures = None + self._files = None + self._files_list = {} + self._options = {} + + self.db_dir = db_dir + self.figures_dir = os.path.join(self.db_dir, "figures") if db_dir else None + self.files_dir = os.path.join(self.db_dir, "files") if db_dir else None + self.experiments_file = os.path.join(self.db_dir, "experiments.json") if db_dir else None + self.results_file = os.path.join(self.db_dir, "results.json") if db_dir else None + if db_dir: + self._create_directories() + + self._init_db() + + def _create_directories(self): + """Creates the directories needed for the DB if they do not exist (internal method)""" + dirs_to_create = [self.db_dir, self.figures_dir, self.files_dir] + for dir_to_create in dirs_to_create: + if not os.path.exists(dir_to_create): + os.makedirs(dir_to_create, exist_ok=True) + + def save(self): + """Saves the db to disk""" + if self.db_dir: + self._experiments.to_json(self.experiments_file) + self._results.to_json(self.results_file) + self._save_figures() + self._save_files() + + def _save_figures(self): + """Saves the figures to disk""" + for exp_id in self._figures: + for figure_name, figure_data in self._figures[exp_id].items(): + filename = f"{exp_id}_{figure_name}" + with open(os.path.join(self.figures_dir, filename), "wb") as file: + file.write(figure_data) + + def _save_files(self): + """Saves the files to disk""" + db_files = set() + for exp_id in self._files: + for file_name, file_data in self._files[exp_id].items(): + full_filename = f"{exp_id}_{file_name}" + db_files.add(full_filename) + file_ext = os.path.splitext(full_filename)[1] + mode = "wb" if file_ext == ".zip" else "w" + encoding = None if mode == "wb" else "utf-8" + with open( + os.path.join(self.files_dir, full_filename), mode, encoding=encoding + ) as file: + file.write(file_data) + current_files = set(os.listdir(self.files_dir)) + stray_files = current_files.difference(db_files) + for file in stray_files: + try: + os.unlink(os.path.join(self.files_dir, file)) + except (OSError, FileNotFoundError): + pass + + def _init_db(self): + """Initializes the db (internal method)""" + if self.db_dir: + if os.path.exists(self.experiments_file): + self._experiments = pd.read_json(self.experiments_file) + else: + self._experiments = pd.DataFrame(columns=self.experiment_db_columns) + + if os.path.exists(self.results_file): + self._results = pd.read_json(self.results_file) + else: + self._results = pd.DataFrame(columns=self.results_db_columns) + + if os.path.exists(self.figures_dir): + self._figures = self._get_figure_list() + else: + self._figures = {} + if os.path.exists(self.files_dir): + self._files, self._files_list = self._get_files() + else: + self._files = {} + else: + self._experiments = pd.DataFrame(columns=self.experiment_db_columns) + self._results = pd.DataFrame(columns=self.results_db_columns) + self._figures = {} + self._files = {} + + self.save() + + @property + def options(self) -> dict: + """Return service options dictionary.""" + return self._options + + def backends(self) -> dict: + """Return the backend list from the experiment DB.""" + return self._experiments.backend.unique().tolist() + + def experiments(self) -> list[str]: + """Retrieve experiment ids + + Returns: + A list of experiment ids. + """ + return self._experiments.experiment_id.unique().tolist() + + def experiment( + self, + experiment_id: str, + json_decoder: type[json.JSONDecoder] = None, # pylint: disable=unused-argument + ) -> DbExperimentData: + """Retrieve a single experiment from the database. + + Args: + experiment_id: Experiment ID + json_decoder: Custom JSON decoder (unused in local service) + + Returns: + Retrieved experiment data + + Raises: + ExperimentEntryNotFound: If the experiment is not found + """ + exp = self._experiments.loc[self._experiments.experiment_id == experiment_id] + if exp.empty: + raise ExperimentEntryNotFound(f"Experiment {experiment_id} not found") + + # Convert the first (and only) row to DbExperimentData + exp_dict = self._prepare_experiment_data(exp.iloc[0].to_dict()) + return DbExperimentData(**exp_dict) + + def create_or_update_experiment( + self, + data: "DbExperimentData", + json_encoder: type[json.JSONEncoder] = json.JSONEncoder, # pylint: disable=unused-argument + create: bool = True, + max_attempts: int = 3, + **kwargs, # pylint: disable=unused-argument + ) -> str: + """Creates a new experiment, or updates an existing one. + + Args: + data: The experiment data to save + json_encoder: Custom JSON encoder (unused) + create: Whether to attempt create first + max_attempts: Maximum number of attempts + **kwargs: Additional parameters (ignored for local service) + + Returns: + Experiment ID + """ + + # Convert DbExperimentData to API format + api_data = {f.name: val for f in fields(data) if (val := getattr(data, f.name)) is not None} + for field in ("creation_datetime", "start_datetime", "end_datetime", "updated_datetime"): + if api_data.get(field): + api_data[field] = api_data[field].isoformat() + + def create_exp(): + return self._experiment_create(api_data) + + def update_exp(): + # Remove fields that shouldn't be updated + update_data = api_data.copy() + for field in [ + "experiment_id", + "device_name", + "group_id", + "hub_id", + "project_id", + "type", + "start_time", + "parent_id", + ]: + update_data.pop(field, None) + return self._experiment_update(data.experiment_id, update_data) + + params = {} + result = self._create_or_update(create_exp, update_exp, params, create, max_attempts) + return DbExperimentData(**result) + + def delete_experiment(self, experiment_id: str) -> None: + """Delete an experiment from the database. + + Args: + experiment_id: Experiment ID to delete + + Raises: + ExperimentEntryNotFound: If the experiment is not found + """ + exp = self._experiments.loc[self._experiments.experiment_id == experiment_id] + if exp.empty: + raise ExperimentEntryNotFound(f"Experiment {experiment_id} not found") + + self._experiments.drop( + self._experiments.loc[self._experiments.experiment_id == experiment_id].index, + inplace=True, + ) + self.save() + + def _prepare_experiment_data(self, row: dict) -> dict: + """Prepare database entry fields for dataclass + + Args: + row: Dataframe row containing experiment data + + Returns: + Dictionary suitable for DbExperimentData initialization + """ + data = row.copy() + + # Convert timestamps + for field in ("creation_datetime", "start_datetime", "end_datetime", "updated_datetime"): + if pd.notna(data.get(field)): + data[field] = datetime.fromisoformat(data[field]) + + list_fields = {"tags", "job_ids"} + str_fields = {"notes", "hub", "group", "project", "owner"} + dict_fields = {"metadata"} + + for key, val in data.items(): + if isinstance(val, float) and pd.isna(val): + if key in list_fields: + data[key] = [] + elif key in str_fields: + data[key] = "" + elif key in dict_fields: + data[key] = {} + else: + data[key] = None + + return data + + def _experiment_create(self, data: dict) -> dict: + """Create an experiment (internal method). + + Args: + data: Experiment data. + + Returns: + Experiment data. + + Raises: + ExperimentEntryExists: If the experiment already exists + + """ + data_dict = data.copy() + now = datetime.now(timezone.utc).isoformat() + for time_field in ("start_datetime", "creation_datetime", "updated_datetime"): + if time_field not in data_dict: + data_dict[time_field] = now + if "tags" not in data_dict: + data_dict["tags"] = [] + if "figure_names" not in data_dict: + data_dict["figure_names"] = [] + + exp = self._experiments.loc[self._experiments.experiment_id == data_dict["experiment_id"]] + if not exp.empty: + raise ExperimentEntryExists + + new_df = pd.DataFrame([data_dict], columns=self._experiments.columns) + self._experiments = pd.concat([self._experiments, new_df], ignore_index=True) + self.save() + exp = self._experiments.loc[self._experiments.experiment_id == data_dict["experiment_id"]] + return self._prepare_experiment_data(exp.to_dict("records")[0]) + + def _experiment_update(self, experiment_id: str, new_data: dict) -> dict: + """Update an experiment (internal method). + + Args: + experiment_id: Experiment UUID. + new_data: New experiment data. + + Returns: + Experiment data. + + Raises: + ExperimentEntryNotFound: If the experiment is not found + """ + new_data = new_data.copy() + exp = self._experiments.loc[self._experiments.experiment_id == experiment_id] + if exp.empty: + raise ExperimentEntryNotFound + exp_index = exp.index[0] + new_data["updated_datetime"] = datetime.now(timezone.utc).isoformat() + for key, value in new_data.items(): + self._experiments.at[exp_index, key] = value + self.save() + exp = self._experiments.loc[self._experiments.experiment_id == experiment_id] + return self._prepare_experiment_data(exp.to_dict("records")[0]) + + def analysis_results( + self, + limit: int | None = None, + backend_name: str | None = None, + device_components: list[str] | None = None, + experiment_id: str | None = None, + result_type: str | None = None, + quality: str | list[str] | None = None, + verified: bool | None = None, + tags: list[str] | None = None, + created_at: list | None = None, + json_decoder: type[json.JSONDecoder] = None, # pylint: disable=unused-argument + ) -> list[DbAnalysisResultData]: + """Return a list of analysis results. + + Args: + limit: Number of analysis results to retrieve. + backend_name: Name of the backend. + device_components: A list of device components used for filtering. + experiment_id: Experiment UUID used for filtering. + result_type: Analysis result type used for filtering. + quality: Quality value used for filtering. + verified: Indicates whether this result has been verified. + tags: Filter by tags assigned to analysis results. + created_at: A list of timestamps used to filter by creation time. + json_decoder: Custom JSON decoder (unused in local service) + + Returns: + A list of analysis results. + Raises: + ValueError: If the parameters are unsuitable for filtering + """ + # pylint: disable=unused-argument + df = self._results + + # TODO: skipping device components for now until we conslidate more with the provider service + # (in the qiskit-experiments service there is no operator for device components, + # so the specification for filtering is not clearly defined) + + if experiment_id is not None: + df = df.loc[df.experiment_id == experiment_id] + if result_type is not None: + df = df.loc[df.result_type == result_type] + if backend_name is not None: + df = df.loc[df.backend_name == backend_name] + if quality is not None: + df = df.loc[df.quality == quality] + if verified is not None: + df = df.loc[df.verified == verified] + + if tags is not None: + tags = tags.split(",") + df = df.loc[df.tags.apply(lambda dftags: any(x in dftags for x in tags))] + + df = df.sort_values(["creation_datetime", "experiment_id"], ascending=[False, True]) + + if limit is not None: + df = df.iloc[:limit] + + # Convert dataframe rows to DbAnalysisResultData objects + results = [] + for _, row in df.iterrows(): + result_dict = self._prepare_analysis_result_data(row.to_dict()) + results.append(DbAnalysisResultData(**result_dict)) + + return results + + def analysis_result( + self, + result_id: str, + json_decoder: type[json.JSONDecoder] = None, # pylint: disable=unused-argument + ) -> DbAnalysisResultData: + """Retrieve a single analysis result from the database. + + Args: + result_id: Analysis result ID + json_decoder: Custom JSON decoder (unused in local service) + + Returns: + Retrieved analysis result data + + Raises: + ExperimentEntryNotFound: If the analysis result is not found + """ + result = self._results.loc[self._results.result_id == result_id] + if result.empty: + raise ExperimentEntryNotFound(f"Analysis result {result_id} not found") + + # Convert the first (and only) row to DbAnalysisResultData + result_dict = self._prepare_analysis_result_data(result.iloc[0].to_dict()) + return DbAnalysisResultData(**result_dict) + + def create_or_update_analysis_result( + self, + data: "DbAnalysisResultData", + json_encoder: type[json.JSONEncoder] = json.JSONEncoder, # pylint: disable=unused-argument + create: bool = True, + max_attempts: int = 3, + ) -> str: + """Creates or updates an analysis result. + + Args: + data: The analysis result data to save + json_encoder: Custom JSON encoder (unused) + create: Whether to attempt create first + max_attempts: Maximum number of attempts + + Returns: + Analysis result ID + """ + + # Convert DbAnalysisResultData to API format + api_data = {f.name: val for f in fields(data) if (val := getattr(data, f.name)) is not None} + if api_data.get("quality"): + api_data["quality"] = ResultQuality.to_str(api_data["quality"]) + for field in ("creation_datetime", "updated_datetime"): + if api_data.get(field): + api_data[field] = api_data[field].isoformat() + + def create_result(): + return self._analysis_result_create(api_data) + + def update_result(): + # Remove fields that shouldn't be updated + update_data = api_data.copy() + for field in ["result_id", "experiment_id", "device_components", "type"]: + update_data.pop(field, None) + return self._analysis_result_update(data.result_id, update_data) + + params = {} + result = self._create_or_update(create_result, update_result, params, create, max_attempts) + # result is a dict from analysis_result_create or _analysis_result_update + return result["result_id"] + + def create_analysis_results( + self, + data: list["DbAnalysisResultData"], + blocking: bool = True, # pylint: disable=unused-argument + max_workers: int = 100, # pylint: disable=unused-argument + json_encoder: type[json.JSONEncoder] = json.JSONEncoder, + ): + """Create multiple analysis results (simplified without threading for local). + + Args: + data: List of analysis result data to save + blocking: Ignored for local service (always blocking) + max_workers: Ignored for local service + json_encoder: Custom JSON encoder + + Returns: + Status dictionary with results + """ + successful = [] + failed = [] + + for result_data in data: + try: + self.create_or_update_analysis_result( + result_data, json_encoder=json_encoder, create=True, max_attempts=3 + ) + successful.append(result_data) + except Exception as ex: # pylint: disable=broad-exception-caught + failed.append({"data": result_data, "exception": ex}) + + return { + "running": [], + "done": successful, + "fail": failed, + } + + def delete_analysis_result(self, result_id: str) -> dict: + """Delete an analysis result. + + Args: + result_id: Analysis result ID. + + Raises: + ExperimentEntryNotFound: If the analysis result is not found + """ + result = self._results.loc[self._results.result_id == result_id] + if result.empty: + raise ExperimentEntryNotFound + self._results.drop( + self._results.loc[self._results.result_id == result_id].index, inplace=True + ) + self.save() + + def _analysis_result_create(self, result: dict) -> dict: + """Upload an analysis result. + + Args: + result: The analysis result to upload + + Returns: + Analysis result data. + + Raises: + ValueError: If experiment id is missing + ExperimentEntryNotFound: If experiment is not found + """ + data_dict = result.copy() + + exp_id = data_dict.get("experiment_id") + if exp_id is None: + raise ValueError("Cannot create analysis result without experiment id") + exp = self._experiments.loc[self._experiments.experiment_id == exp_id] + if exp.empty: + raise ExperimentEntryNotFound(f"Experiment {exp_id} not found") + exp_index = exp.index[0] + data_dict["backend_name"] = self._experiments.at[exp_index, "backend"] + now = datetime.now(timezone.utc).isoformat() + if data_dict.get("creation_datetime") is None: + data_dict["creation_datetime"] = now + if data_dict.get("updated_datetime") is None: + data_dict["updated_datetime"] = now + + new_df = pd.DataFrame([data_dict], columns=self._results.columns) + self._results = pd.concat([self._results, new_df], ignore_index=True) + self.save() + return data_dict + + def _analysis_result_update(self, result_id: str, new_data: dict) -> dict: + """Update an analysis result (internal method). + + Args: + result_id: Analysis result ID. + new_data: New analysis result data. + + Returns: + Analysis result data. + + Raises: + ExperimentEntryNotFound: If the analysis result is not found + """ + new_data = new_data.copy() + result = self._results.loc[self._results.result_id == result_id] + if result.empty: + raise ExperimentEntryNotFound + result_index = result.index[0] + new_data["updated_datetime"] = datetime.now(timezone.utc).isoformat() + for key, value in new_data.items(): + self._results.at[result_index, key] = value + self.save() + result = self._results.loc[self._results.result_id == result_id] + return self._prepare_analysis_result_data(result.to_dict("records")[0]) + + def _prepare_analysis_result_data(self, row: dict) -> dict: + """Prepare row dict from database for analysis result dataclass. + + Args: + row: Dataframe row containing analysis result data + + Returns: + Dictionary suitable for DbAnalysisResultData initialization + """ + data = row.copy() + + # Convert timestamps + for field in ("creation_datetime", "updated_datetime"): + if pd.notna(data.get(field)): + data[field] = datetime.fromisoformat(data[field]) + + list_fields = {"device_components", "tags"} + str_fields = {"notes", "hub", "group", "project", "owner"} + dict_fields = {"result_data"} + bool_fields = {"verified"} + + for key, val in data.items(): + if isinstance(val, float) and pd.isna(val): + if key in list_fields: + data[key] = [] + elif key in str_fields: + data[key] = "" + elif key in dict_fields: + data[key] = {} + elif key in bool_fields: + data[key] = False + else: + data[key] = None + + if "quality" in data and isinstance(data["quality"], str): + data["quality"] = ResultQuality.from_str(data["quality"]) + + return data + + def figure( + self, experiment_id: str, figure_name: str, file_name: str | None = None + ) -> int | bytes: + """Retrieve an existing figure. + + Args: + experiment_id: Experiment ID + figure_name: Name of the figure + file_name: Local file to save to (if None, returns bytes) + + Returns: + Size if file_name given, otherwise figure bytes + """ + data = self._figure_get(experiment_id, figure_name) + + if file_name: + with open(file_name, "wb") as file: + num_bytes = file.write(data) + return num_bytes + + return data + + def create_or_update_figure( + self, + experiment_id: str, + figure: str | bytes, + figure_name: str | None = None, + create: bool = True, + max_attempts: int = 3, + ) -> tuple: + """Creates a figure if it doesn't exist, otherwise updates it. + + Args: + experiment_id: Experiment ID + figure: Figure file name or figure data + figure_name: Name of the figure + create: Whether to attempt create first + max_attempts: Maximum number of attempts + + Returns: + Tuple of (figure_name, size) + """ + params = { + "experiment_id": experiment_id, + "figure": figure, + "figure_name": figure_name, + } + return self._create_or_update( + self._figure_create, self._figure_update, params, create, max_attempts + ) + + def create_figures( + self, + experiment_id: str, + figure_list: list[tuple], + blocking: bool = True, # pylint: disable=unused-argument + max_workers: int = 100, # pylint: disable=unused-argument + ): + """Create multiple figures (simplified without threading for local). + + Args: + experiment_id: ID of the experiment + figure_list: List of (figure, name) tuples + blocking: Ignored for local service + max_workers: Ignored for local service + + Returns: + Status dictionary with results + """ + successful = [] + failed = [] + + for figure, figure_name in figure_list: + try: + self.create_or_update_figure( + experiment_id=experiment_id, + figure=figure, + figure_name=figure_name, + create=True, + max_attempts=3, + ) + successful.append((figure, figure_name)) + except Exception as ex: # pylint: disable=broad-exception-caught + failed.append({"data": (figure, figure_name), "exception": ex}) + + return { + "running": [], + "done": successful, + "fail": failed, + } + + def delete_figure(self, experiment_id: str, figure_name: str) -> None: + """Delete an experiment plot. + + Args: + experiment_id: Experiment ID + figure_name: Name of the figure + """ + try: + self._figure_delete(experiment_id, figure_name) + except ExperimentEntryNotFound: + logger.warning("Figure %s not found.", figure_name) + + def _get_figure_list(self): + """Generates the figure dictionary based on stored data on disk""" + figures = {} + for exp_id in self._experiments.experiment_id: + # exp_id should be str to begin with, so just in case + exp_id_string = str(exp_id) + figures_for_exp = {} + for filename in os.listdir(self.figures_dir): + if filename.startswith(exp_id_string): + with open(os.path.join(self.figures_dir, filename), "rb") as file: + figure_data = file.read() + figure_name = filename[len(exp_id_string) + 1 :] + figures_for_exp[figure_name] = figure_data + figures[exp_id] = figures_for_exp + return figures + + def _figure_create( + self, + experiment_id: str, + figure: str | bytes, + figure_name: str | None = None, + ) -> tuple: + """Store a new figure in the database (internal method). + + Args: + experiment_id: ID of the experiment + figure: Figure file name or figure data + figure_name: Name of the figure + + Returns: + Tuple of (figure_name, size) + """ + if figure_name is None: + if isinstance(figure, str): + figure_name = figure + else: + figure_name = f"figure_{datetime.now(timezone.utc).isoformat()}.svg" + + if not figure_name.endswith(".svg"): + figure_name += ".svg" + + if isinstance(figure, str): + with open(figure, "rb") as file: + figure = file.read() + + if experiment_id not in self._figures: + self._figures[experiment_id] = {} + exp_figures = self._figures[experiment_id] + if figure_name in exp_figures: + raise ExperimentEntryExists(f"Figure {figure_name} already exists") + exp_figures[figure_name] = figure + self.save() + + return figure_name, len(figure) + + def _figure_get(self, experiment_id: str, plot_name: str) -> bytes: + """Retrieve an experiment plot (internal method). + + Args: + experiment_id: Experiment UUID. + plot_name: Name of the plot. + + Returns: + Retrieved experiment plot. + + Raises: + ExperimentEntryNotFound: If the figure is not found + """ + + exp_figures = self._figures[experiment_id] + if plot_name not in exp_figures: + raise ExperimentEntryNotFound(f"Figure {plot_name} not found") + return exp_figures[plot_name] + + def _figure_update( + self, + experiment_id: str, + figure: str | bytes, + figure_name: str, + ) -> tuple: + """Update an existing figure (internal method). + + Args: + experiment_id: Experiment ID + figure: Figure file name or figure data + figure_name: Name of the figure + + Returns: + Tuple of (figure_name, size) + """ + if not figure_name.endswith(".svg"): + figure_name += ".svg" + + if isinstance(figure, str): + with open(figure, "rb") as file: + figure = file.read() + + exp_figures = self._figures[experiment_id] + if figure_name not in exp_figures: + raise ExperimentEntryNotFound(f"Figure {figure_name} not found") + exp_figures[figure_name] = figure + self.save() + + return figure_name, len(figure) + + def _figure_delete(self, experiment_id: str, plot_name: str) -> None: + """Delete an experiment plot (internal method). + + Args: + experiment_id: Experiment UUID. + plot_name: Plot file name. + + Raises: + ExperimentEntryNotFound: If the figure is not found + """ + exp_figures = self._figures[experiment_id] + if plot_name not in exp_figures: + raise ExperimentEntryNotFound(f"Figure {plot_name} not found") + del exp_figures[plot_name] + + def files(self, experiment_id: str) -> dict: + """Retrieve the file list for an experiment. + + Args: + experiment_id: Experiment ID + + Returns: + Dictionary with file list metadata (format: {"files": [...]}) + """ + return {"files": self._files_list.get(experiment_id, [])} + + def experiment_has_file(self, experiment_id: str, filename: str) -> bool: + """Check if an experiment has a specific file. + + Args: + experiment_id: Experiment ID + filename: Name of the file to check + + Returns: + True if the file exists, False otherwise + """ + if experiment_id not in self._files: + return False + return filename in self._files[experiment_id] + + def file_upload( + self, + experiment_id: str, + file_name: str, + file_data: dict | str | bytes, + json_encoder: type[json.JSONEncoder] = json.JSONEncoder, + ): + """Uploads a data file to the DB. + + Args: + experiment_id: The experiment the file belongs to + file_name: The expected filename + file_data: Dictionary or JSON string or bytes to save + json_encoder: Custom JSON encoder + + Raises: + RuntimeError: pyyaml not available and a yaml file requested + """ + # Ensure proper file extension + if not ( + file_name.endswith(".json") or file_name.endswith(".yaml") or file_name.endswith(".zip") + ): + file_name += ".json" + + if isinstance(file_data, dict): + if file_name.endswith(".yaml"): + try: + import yaml + except ImportError as err: + raise RuntimeError("pyyaml required to store yaml file!") from err + file_data = yaml.dump(file_data) + elif file_name.endswith(".json"): + file_data = json.dumps(file_data, cls=json_encoder) + + if experiment_id not in self._files_list: + self._files_list[experiment_id] = [] + if experiment_id not in self._files: + self._files[experiment_id] = {} + size = len(file_data) + new_file_element = { + "Key": file_name, + "Size": size, + "LastModified": datetime.now(timezone.utc).isoformat(), + } + self._files_list[experiment_id].append(new_file_element) + self._files[experiment_id][file_name] = file_data + self.save() + + def file_delete( + self, + experiment_id: str, + file_name: str, + ): + """Delete a file from the database""" + if not ( + file_name.endswith(".json") or file_name.endswith(".yaml") or file_name.endswith(".zip") + ): + file_name += ".json" + + if experiment_id not in self._files: + raise ExperimentEntryNotFound + if file_name not in self._files[experiment_id]: + raise ExperimentEntryNotFound + + del self._files[experiment_id][file_name] + self._files_list[experiment_id] = [ + e for e in self._files_list[experiment_id] if e["Key"] != file_name + ] + self.save() + + def file_download( + self, + experiment_id: str, + file_name: str, + json_decoder: type[json.JSONDecoder] = json.JSONDecoder, + ) -> dict: + """Downloads a data file from the DB. + + Args: + experiment_id: The experiment the file belongs to + file_name: The filename + json_decoder: Custom JSON decoder + + Returns: + Deserialized file data + + Raises: + ExperimentEntryNotFound: File not found + RuntimeError: pyyaml not available and a yaml file requested + """ + if not ( + file_name.endswith(".json") or file_name.endswith(".yaml") or file_name.endswith(".zip") + ): + file_name += ".json" + + if experiment_id not in self._files: + raise ExperimentEntryNotFound + if file_name not in self._files[experiment_id]: + raise ExperimentEntryNotFound + if file_name.endswith(".yaml"): + try: + import yaml + except ImportError as err: + raise RuntimeError("pyyaml required to load yaml file!") from err + return yaml.safe_load(self._files[experiment_id][file_name]) + elif file_name.endswith(".json"): + return json.loads(self._files[experiment_id][file_name], cls=json_decoder) + return self._files[experiment_id][file_name] + + def _get_files(self): + """Generates the figure dictionary based on stored data on disk""" + files = {} + files_list = {} + for exp_id in self._experiments.experiment_id: + # exp_id should be str to begin with, so just in case + exp_id_string = str(exp_id) + file_list_for_exp = [] + files_for_exp = {} + for filename in os.listdir(self.files_dir): + if filename.startswith(exp_id_string): + file_full_path = os.path.join(self.files_dir, filename) + file_ext = os.path.splitext(filename)[1] + mode = "rb" if file_ext == ".zip" else "r" + encoding = None if mode == "rb" else "utf-8" + with open(file_full_path, mode, encoding=encoding) as file: + file_data = file.read() + file_size = len(file_data) + file_name = filename[len(exp_id_string) + 1 :] + files_for_exp[file_name] = file_data + new_file_element = { + "Key": file_name, + "Size": file_size, + "LastModified": os.path.getmtime(file_full_path), + } + file_list_for_exp.append(new_file_element) + files_list[exp_id] = file_list_for_exp + files[exp_id] = files_for_exp + return files, files_list + + def _experiment_files_get(self, experiment_id: str) -> dict[str, list[str]]: + """Retrieve experiment related files (internal method). + + Args: + experiment_id: Experiment ID. + + Returns: + Experiment files. + """ + return {"files": self._files_list.get(experiment_id, [])} + + def _experiment_file_download_impl( + self, experiment_id: str, file_name: str, json_decoder: type[json.JSONDecoder] + ) -> dict: + """Downloads a data file from the DB (internal implementation) + + Args: + experiment_id: Experiment ID. + file_name: The name of the data file + json_decoder: Custom decoder to use to decode the retrieved experiment. + + Returns: + The Dictionary of contents of the file + + Raises: + ExperimentEntryNotFound: if experiment or file not found + """ + if experiment_id not in self._files: + raise ExperimentEntryNotFound + if file_name not in self._files[experiment_id]: + raise ExperimentEntryNotFound + if file_name.endswith(".yaml"): + try: + import yaml + except ImportError as err: + raise RuntimeError("pyyaml required to load yaml file!") from err + return yaml.safe_load(self._files[experiment_id][file_name]) + elif file_name.endswith(".json"): + return json.loads(self._files[experiment_id][file_name], cls=json_decoder) + return self._files[experiment_id][file_name] + + def _create_or_update( + self, + create_func, + update_func, + params, + create: bool = True, + max_attempts: int = 3, + ): + """Creates or updates a database entry using the given functions. + + Args: + create_func: Function to create new entry + update_func: Function to update existing entry + params: Parameters to pass to the functions + create: Whether to attempt create first + max_attempts: Maximum number of attempts + + Returns: + Result from the successful function call + """ + attempts = 0 + success = False + result = None + while attempts < max_attempts and not success: + attempts += 1 + if create: + try: + result = create_func(**params) + success = True + except ExperimentEntryExists: + create = False + else: + try: + result = update_func(**params) + success = True + except ExperimentEntryNotFound: + create = True + return result + + def _convert_db_to_dict(self, dataframe: pd.DataFrame): + """Prepares db values for dataclasses""" + result = dataframe.replace({np.nan: None}).to_dict("records")[0] + return result diff --git a/qiskit_experiments/database_service/utils.py b/qiskit_experiments/database_service/utils.py index 073d0cf3e4..2b585881f8 100644 --- a/qiskit_experiments/database_service/utils.py +++ b/qiskit_experiments/database_service/utils.py @@ -27,11 +27,6 @@ import dateutil.parser from dateutil import tz -from qiskit_ibm_experiment import ( - IBMExperimentEntryExists, - IBMExperimentEntryNotFound, -) - from .exceptions import ExperimentEntryNotFound, ExperimentEntryExists, ExperimentDataError LOG = logging.getLogger(__name__) @@ -91,7 +86,7 @@ def objs_to_zip( zip_file.writestr(f"{filename}.json", json.dumps(data, cls=json_encoder)) zip_buffer.seek(0) - return zip_buffer + return zip_buffer.read() def zip_to_objs(zip_bytes: bytes, json_decoder: json.JSONDecoder | None = None) -> Iterator[any]: @@ -163,8 +158,6 @@ def save_data( ExperimentDataError: If unable to determine whether the entry exists. """ attempts = 0 - no_entry_exception = (ExperimentEntryNotFound, IBMExperimentEntryNotFound) - dup_entry_exception = (ExperimentEntryExists, IBMExperimentEntryExists) try: kwargs = {} @@ -180,13 +173,13 @@ def save_data( kwargs.update(new_data) kwargs.update(update_data) return True, new_func(**kwargs) - except dup_entry_exception: + except ExperimentEntryExists: is_new = False else: try: kwargs.update(update_data) return True, update_func(**kwargs) - except no_entry_exception: + except ExperimentEntryNotFound: is_new = True raise ExperimentDataError("Unable to determine the existence of the entry.") except Exception: # pylint: disable=broad-except diff --git a/qiskit_experiments/framework/__init__.py b/qiskit_experiments/framework/__init__.py index b4a35eaad1..51aad8777c 100644 --- a/qiskit_experiments/framework/__init__.py +++ b/qiskit_experiments/framework/__init__.py @@ -94,7 +94,7 @@ FigureData Provider BaseProvider - IBMProvider + ExperimentService Job BaseJob ExtendedJob @@ -149,8 +149,8 @@ from .provider_interfaces import ( BaseJob, BaseProvider, + ExperimentService, ExtendedJob, - IBMProvider, Job, MeasLevel, MeasReturnType, diff --git a/qiskit_experiments/framework/analysis_result.py b/qiskit_experiments/framework/analysis_result.py index 8bb59d9bd4..2cbf55b3a0 100644 --- a/qiskit_experiments/framework/analysis_result.py +++ b/qiskit_experiments/framework/analysis_result.py @@ -21,8 +21,6 @@ import uncertainties -from qiskit_ibm_experiment import IBMExperimentService, AnalysisResultData -from qiskit_ibm_experiment import ResultQuality from qiskit.exceptions import QiskitError from qiskit_experiments.framework.json import ( @@ -30,10 +28,15 @@ ExperimentDecoder, _serialize_safe_float, ) - -from qiskit_experiments.database_service.device_component import DeviceComponent, to_component -from qiskit_experiments.database_service.exceptions import ExperimentDataError +from qiskit_experiments.database_service import ( + DbAnalysisResultData, + DeviceComponent, + ExperimentDataError, + ResultQuality, + to_component, +) from qiskit_experiments.framework.package_deps import qiskit_version +from qiskit_experiments.framework.provider_interfaces import ExperimentService LOG = logging.getLogger(__name__) @@ -78,12 +81,6 @@ class AnalysisResult: _extra_data = {} - RESULT_QUALITY_TO_TEXT = { - ResultQuality.GOOD: "good", - ResultQuality.BAD: "bad", - ResultQuality.UNKNOWN: "unknown", - } - def __init__( self, name: str = None, @@ -92,11 +89,11 @@ def __init__( experiment_id: str = None, result_id: str | None = None, chisq: float | None = None, - quality: str | None = RESULT_QUALITY_TO_TEXT[ResultQuality.UNKNOWN], + quality: str | None = ResultQuality.UNKNOWN.value, extra: dict[str, Any] | None = None, verified: bool = False, tags: list[str] | None = None, - service: IBMExperimentService | None = None, + service: ExperimentService | None = None, source: dict[str, str] | None = None, ) -> "AnalysisResult": """AnalysisResult constructor. @@ -120,12 +117,12 @@ def __init__( The AnalysisResult object. """ # Data to be stored in DB. - self._db_data = AnalysisResultData( + self._db_data = DbAnalysisResultData( experiment_id=experiment_id, result_id=result_id or str(uuid.uuid4()), result_type=name, chisq=chisq, - quality=quality, + quality=ResultQuality.from_str(quality), verified=verified, tags=tags or [], ) @@ -145,12 +142,11 @@ def __init__( except AttributeError: pass - def set_data(self, data: AnalysisResultData): + def set_data(self, data: DbAnalysisResultData): """Sets the analysis data stored in the class""" self._db_data = data new_device_components = [to_component(comp) for comp in self._db_data.device_components] self._db_data.device_components = new_device_components - self._db_data.quality = self.RESULT_QUALITY_TO_TEXT.get(self._db_data.quality, "unknown") @classmethod def default_source(cls) -> dict[str, str]: @@ -189,7 +185,7 @@ def format_result_data(value, extra, chisq, source): return result_data @classmethod - def load(cls, result_id: str, service: IBMExperimentService) -> "AnalysisResult": + def load(cls, result_id: str, service: ExperimentService) -> "AnalysisResult": """Load a saved analysis result from a database service. Args: @@ -399,7 +395,7 @@ def tags(self, new_tags: list[str]) -> None: self.save() @property - def service(self) -> IBMExperimentService | None: + def service(self) -> ExperimentService | None: """Return the database service. Returns: @@ -409,7 +405,7 @@ def service(self) -> IBMExperimentService | None: return self._service @service.setter - def service(self, service: IBMExperimentService) -> None: + def service(self, service: ExperimentService) -> None: """Set the service to be used for storing result data in a database. Args: diff --git a/qiskit_experiments/framework/analysis_result_data.py b/qiskit_experiments/framework/analysis_result_data.py index 956c23453e..0b16fd062b 100644 --- a/qiskit_experiments/framework/analysis_result_data.py +++ b/qiskit_experiments/framework/analysis_result_data.py @@ -16,7 +16,7 @@ import logging from typing import Any -from qiskit_experiments.database_service.device_component import DeviceComponent +from qiskit_experiments.database_service import DeviceComponent, ResultQuality LOG = logging.getLogger(__name__) @@ -30,7 +30,7 @@ class AnalysisResultData: value: Any experiment: str = None chisq: float | None = None - quality: str | None = None + quality: str = ResultQuality.UNKNOWN.value experiment_id: str | None = None result_id: str | None = None tags: list = dataclasses.field(default_factory=list) diff --git a/qiskit_experiments/framework/containers/artifact_data.py b/qiskit_experiments/framework/containers/artifact_data.py index 0a89622d65..06d47d482c 100644 --- a/qiskit_experiments/framework/containers/artifact_data.py +++ b/qiskit_experiments/framework/containers/artifact_data.py @@ -32,7 +32,7 @@ class ArtifactData: fit status, and any other JSON-based data needed to serialize experiments and experiment data. Attributes: - name: The name of the artifact. When saved to the cloud service, this will be the name + name: The name of the artifact. When saved in an experiment service, this will be the name of the zipfile this artifact object is stored in. data: The artifact payload. artifact_id: Artifact ID. Must be unique inside an :class:`ExperimentData` object. diff --git a/qiskit_experiments/framework/experiment_data.py b/qiskit_experiments/framework/experiment_data.py index fab589eed3..6d94b179e2 100644 --- a/qiskit_experiments/framework/experiment_data.py +++ b/qiskit_experiments/framework/experiment_data.py @@ -40,13 +40,6 @@ from qiskit.providers import Backend from qiskit.primitives import BitArray, SamplerPubResult -from qiskit_ibm_experiment import ( - IBMExperimentService, - ExperimentData as ExperimentDataclass, - AnalysisResultData as AnalysisResultDataclass, - ResultQuality, -) - from qiskit_experiments.framework.json import ExperimentEncoder, ExperimentDecoder from qiskit_experiments.database_service.utils import ( plot_to_svg_bytes, @@ -62,15 +55,18 @@ from qiskit_experiments.framework import ExperimentStatus, AnalysisStatus, AnalysisCallback from qiskit_experiments.framework.deprecation import warn_from_qe from qiskit_experiments.framework.package_deps import qiskit_version -from qiskit_experiments.database_service.exceptions import ( +from qiskit_experiments.database_service import ( + DbAnalysisResultData, + DbExperimentData, ExperimentDataError, ExperimentEntryNotFound, ExperimentDataSaveFailed, + ResultQuality, ) from qiskit_experiments.database_service.utils import objs_to_zip, zip_to_objs from .containers.figure_data import FigureData, FigureType -from .provider_interfaces import Job, Provider +from .provider_interfaces import ExperimentService, Job, Provider if TYPE_CHECKING: @@ -125,11 +121,6 @@ def get_job_status(job: Job) -> JobStatus: class ExperimentData: """Experiment data container class. - .. note:: - Saving experiment data to the cloud database is currently a limited access feature. You can - check whether you have access by logging into the IBM Quantum interface - and seeing if you can see the `database `__. - This class handles the following: 1. Storing the data related to an experiment: raw data, metadata, analysis results, @@ -137,7 +128,7 @@ class ExperimentData: 2. Managing jobs and adding data from jobs automatically 3. Saving and loading data from the database service - The field ``db_data`` is a dataclass (``ExperimentDataclass``) containing + The field ``db_data`` is a :class:`.DbExperimentData` dataclass containing all the data that can be stored in the database and loaded from it, and as such is subject to strict conventions. @@ -159,13 +150,13 @@ def __init__( self, experiment: BaseExperiment | None = None, backend: Backend | None = None, - service: IBMExperimentService | None = None, + service: ExperimentService | None = None, provider: Provider | None = None, parent_id: str | None = None, job_ids: list[str] | None = None, child_data: list[ExperimentData] | None = None, verbose: bool | None = True, - db_data: ExperimentDataclass | None = None, + db_data: DbExperimentData | None = None, start_datetime: datetime | None = None, **kwargs, ): @@ -176,8 +167,7 @@ def __init__( backend: Backend the experiment runs on. This overrides the backend in the experiment object. service: The service that stores the experiment results to the database - provider: The provider used for the experiments - (can be used to automatically obtain the service) + provider: The provider used for the experiment parent_id: ID of the parent experiment data in the setting of a composite experiment job_ids: IDs of jobs submitted for the experiment. @@ -187,15 +177,6 @@ def __init__( This overrides other db parameters. start_datetime: The time when the experiment started running. If none, defaults to the current time. - - Additional info: - In order to save the experiment data to the cloud service, the class - needs access to the experiment service provider. It can be obtained - via three different methods, given here by priority: - - 1. Passing it directly via the ``service`` parameter. - 2. Implicitly obtaining it from the ``provider`` parameter. - 3. Implicitly obtaining it from the ``backend`` parameter, using that backend's provider. """ if experiment is not None: backend = backend or experiment.backend @@ -223,7 +204,7 @@ def __init__( metadata["_source"] = source experiment_id = kwargs.get("experiment_id", str(uuid.uuid4())) if db_data is None: - self._db_data = ExperimentDataclass( + self._db_data = DbExperimentData( experiment_id=experiment_id, experiment_type=experiment_type, parent_id=parent_id, @@ -254,8 +235,8 @@ def __init__( # qiskit_ibm_runtime.IBMBackend stores its Provider-like object in # the "service" attribute self._provider = backend.service - # Experiment service like qiskit_ibm_experiment.IBMExperimentService, - # not to be confused with qiskit_ibm_runtime.QiskitRuntimeService + # ExperimentService service not to be confused with + # qiskit_ibm_runtime.QiskitRuntimeService self._service = service self._auto_save = False self._created_in_db = False @@ -362,10 +343,14 @@ def metadata(self) -> dict: def creation_datetime(self) -> datetime: """Return the creation datetime of this experiment data. - Returns: - The timestamp when this experiment data was saved to the cloud service - in the local timezone. + .. note:: + Typically this value is set within the experiment service. It might + not be available on a new :class:`.ExperimentData` instance prior + to reloading it from the service. + + Returns: + The timestamp when this experiment data was saved to the experiment service. """ return self._db_data.creation_datetime @@ -374,8 +359,7 @@ def start_datetime(self) -> datetime: """Return the start datetime of this experiment data. Returns: - The timestamp when this experiment began running in the local timezone. - + The timestamp when this experiment began running. """ return self._db_data.start_datetime @@ -387,9 +371,14 @@ def start_datetime(self, new_start_datetime: datetime) -> None: def updated_datetime(self) -> datetime: """Return the update datetime of this experiment data. + .. note:: + + Typically this value is set within the experiment service. It might + not be available on a new :class:`.ExperimentData` instance prior + to reloading it from the service. + Returns: - The timestamp when this experiment data was last updated in the service - in the local timezone. + The timestamp when this experiment data was last updated in the service. """ return self._db_data.updated_datetime @@ -417,8 +406,6 @@ def end_datetime(self) -> datetime: Returns: The timestamp when the last job of this experiment finished - in the local timezone. - """ return self._db_data.end_datetime @@ -432,7 +419,6 @@ def hub(self) -> str: Returns: The hub of this experiment data. - """ return self._db_data.hub @@ -452,7 +438,6 @@ def project(self) -> str: Returns: The project of this experiment data. - """ return self._db_data.project @@ -527,9 +512,7 @@ def share_level(self, new_level: str) -> None: to this experiment itself and its descendants. Args: - new_level: New experiment share level. Valid share levels are provider- - specified. For example, IBM Quantum experiment service allows - "public", "hub", "group", "project", and "private". + new_level: New experiment share level. """ self._db_data.share_level = new_level for data in self._child_data.values(): @@ -633,7 +616,7 @@ def _clear_results(self): self._db_data.figure_names.clear() @property - def service(self) -> IBMExperimentService | None: + def service(self) -> ExperimentService | None: """Return the database service. Returns: @@ -642,7 +625,7 @@ def service(self) -> IBMExperimentService | None: return self._service @service.setter - def service(self, service: IBMExperimentService) -> None: + def service(self, service: ExperimentService) -> None: """Set the service to be used for storing experiment data Args: @@ -653,30 +636,7 @@ def service(self, service: IBMExperimentService) -> None: """ self._set_service(service) - def _infer_service(self, warn: bool): - """Try to configure service if it has not been configured - - This method should be called before any method that needs to work with - the experiment service. - - Args: - warn: Warn if the service could not be set up from the backend or - provider attributes. - - Returns: - True if a service instance has been set up - """ - if self.service is None: - self.service = self.get_service_from_backend(self.backend) - if self.service is None: - self.service = self.get_service_from_provider(self.provider) - - if warn and self.service is None: - LOG.warning("Experiment service has not been configured. Can not save!") - - return self.service is not None - - def _set_service(self, service: IBMExperimentService) -> None: + def _set_service(self, service: ExperimentService) -> None: """Set the service to be used for storing experiment data, to this experiment itself and its descendants. @@ -694,32 +654,6 @@ def _set_service(self, service: IBMExperimentService) -> None: for data in self.child_data(): data._set_service(service) - @staticmethod - def get_service_from_backend(backend) -> IBMExperimentService | None: - """Initializes the service from the backend data""" - # backend.provider is not checked since currently the only viable way - # to set up the experiment service is using the credentials from - # QiskitRuntimeService on a qiskit_ibm_runtime.IBMBackend. - provider = getattr(backend, "service", None) - return ExperimentData.get_service_from_provider(provider) - - @staticmethod - def get_service_from_provider(provider) -> IBMExperimentService | None: - """Initializes the service from the provider data""" - if not hasattr(provider, "active_account"): - return None - - account = provider.active_account() - url = account.get("url") - token = account.get("token") - try: - if url is not None and token is not None: - return IBMExperimentService(token=token, url=url) - except Exception: # pylint: disable=broad-except - LOG.warning("Failed to connect to experiment service", exc_info=True) - - return None - @property def provider(self) -> Provider | None: """Return the backend provider. @@ -1174,8 +1108,7 @@ def _retrieve_data(self): if not self._result_data.copy(): # Adding warning so the user will have indication why the analysis may fail. LOG.warning( - "Provider for ExperimentData object doesn't exist, resulting in a failed attempt to" - " retrieve data from the server; no stored result data exists" + "Provider for ExperimentData object is unset. Job data is not available." ) return retrieved_jobs = {} @@ -1351,7 +1284,7 @@ def add_figures( self._db_data.figure_names.append(fig_name) save = save_figure if save_figure is not None else self.auto_save - if save and self._infer_service(warn=True): + if save: if isinstance(figure, pyplot.Figure): figure = plot_to_svg_bytes(figure) self.service.create_or_update_figure( @@ -1369,7 +1302,7 @@ def delete_figure( self, figure_key: str | int, ) -> str: - """Add the experiment figure. + """Delete the experiment figure. Args: figure_key: Name or index of the figure. @@ -1384,8 +1317,9 @@ def delete_figure( del self._figures[figure_key] self._deleted_figures.append(figure_key) + self._db_data.figure_names.remove(figure_key) - if self.auto_save and self._infer_service(warn=True): + if self.auto_save: with service_exception_to_warning(): self.service.delete_figure(experiment_id=self.experiment_id, figure_name=figure_key) self._deleted_figures.remove(figure_key) @@ -1434,7 +1368,7 @@ def figure( figure_key = self._find_figure_key(figure_key) figure_data = self._figures.get(figure_key, None) - if figure_data is None and self._infer_service(warn=False): + if figure_data is None and self.service: figure = self.service.figure(experiment_id=self.experiment_id, figure_name=figure_key) figure_data = FigureData(figure=figure, name=figure_key) self._figures[figure_key] = figure_data @@ -1606,7 +1540,7 @@ def add_analysis_results( created_time=created_time, **extra_values, ) - if self.auto_save and self._infer_service(warn=True): + if self.auto_save: service_result = _series_to_service_result( series=self._analysis_results.get_data(uid, columns="all").iloc[0], service=self.service, @@ -1630,14 +1564,16 @@ def delete_analysis_result( Raises: ExperimentEntryNotFound: If analysis result not found or multiple entries are found. """ + deleted_data = self._analysis_results.get_data(result_key, columns="all") + result_ids = deleted_data.result_id.unique() uids = self._analysis_results.del_data(result_key) - if self.auto_save and self._infer_service(warn=True): + if self.auto_save: with service_exception_to_warning(): - for uid in uids: + for uid in result_ids: self.service.delete_analysis_result(result_id=uid) else: - self._deleted_analysis_results.extend(uids) + self._deleted_analysis_results.extend(result_ids) return uids @@ -1654,9 +1590,8 @@ def _retrieve_analysis_results(self, refresh: bool = False): experiment_id=self.experiment_id, limit=None, json_decoder=self._json_decoder ) for result in retrieved_results: - # Canonicalize IBM specific data structure. # TODO define proper data schema on frontend and delegate this to service. - cano_quality = AnalysisResult.RESULT_QUALITY_TO_TEXT.get(result.quality, "unknown") + cano_quality = ResultQuality.to_str(result.quality) cano_components = [to_component(c) for c in result.device_components] extra = result.result_data["_extra"] if result.chisq is not None: @@ -1781,7 +1716,7 @@ def analysis_results( ), DeprecationWarning, ) - # Convert back into List[AnalysisResult] which is payload for IBM experiment service. + # Convert back into List[AnalysisResult]. # This will be removed in future version. tmp_df = self._analysis_results.get_data(index, columns="all") service_results = [] @@ -1813,34 +1748,32 @@ def save_metadata(self) -> None: This method does not save analysis results, figures, or artifacts. Use :meth:`save` for general saving of all experiment data. - See :meth:`qiskit.providers.experiment.IBMExperimentService.create_experiment` - for fields that are saved. + This method uses + :meth:`qiskit_experiments.database_service.ExperimentService.create_or_update_experiment` """ - self._infer_service(warn=False) self._save_experiment_metadata() for data in self.child_data(): data.save_metadata() def _save_experiment_metadata(self, suppress_errors: bool = True) -> None: """Save this experiments metadata to a database service. + Args: suppress_errors: should the method catch exceptions (true) or pass them on, potentially aborting the experiment (false) + Raises: QiskitError: If the save to the database failed + .. note:: This method does not save analysis results nor figures. Use :meth:`save` for general saving of all experiment data. - See :meth:`qiskit.providers.experiment.IBMExperimentService.create_experiment` - for fields that are saved. + This method uses + :meth:`qiskit_experiments.database_service.ExperimentService.create_or_update_experiment` """ if not self.service: - LOG.warning( - "Experiment cannot be saved because no experiment service is available. " - "An experiment service is available, for example, " - "when using an IBM Quantum backend." - ) + LOG.warning("Experiment cannot be saved because no experiment service is available.") return try: handle_metadata_separately = self._metadata_too_large() @@ -1851,11 +1784,10 @@ def _save_experiment_metadata(self, suppress_errors: bool = True) -> None: result = self.service.create_or_update_experiment( self._db_data, json_encoder=self._json_encoder, create=not self._created_in_db ) - if isinstance(result, dict): - created_datetime = result.get("created_at", None) - updated_datetime = result.get("updated_at", None) - self._db_data.creation_datetime = parse_utc_datetime(created_datetime) - self._db_data.updated_datetime = parse_utc_datetime(updated_datetime) + if hasattr(result, "start_datetime"): + self._db_data.creation_datetime = result.start_datetime + if hasattr(result, "updated_datetime"): + self._db_data.updated_datetime = result.updated_datetime self._created_in_db = True @@ -1913,13 +1845,8 @@ def save( additional tags or notes) use :meth:`save_metadata`. """ # TODO - track changes - self._infer_service(warn=False) if not self.service: - LOG.warning( - "Experiment cannot be saved because no experiment service is available. " - "An experiment service is available, for example, " - "when using an IBM Quantum backend." - ) + LOG.warning("Experiment cannot be saved because no experiment service is available.") if suppress_errors: return else: @@ -2021,27 +1948,14 @@ def save( except Exception: # pylint: disable=broad-except: LOG.error("Unable to save artifacts: %s", traceback.format_exc()) - # Upload a blank file if the whole file should be deleted - # TODO: replace with direct artifact deletion when available for artifact_name in self._deleted_artifacts.copy(): - try: # Don't overwrite with a blank file if there's still artifacts with this name - self.artifacts(artifact_name) - except Exception: # pylint: disable=broad-except: - with service_exception_to_warning(): - self.service.file_upload( - experiment_id=self.experiment_id, - file_name=f"{artifact_name}.zip", - file_data=None, - ) - # Even if we didn't overwrite an artifact file, we don't need to keep this because - # an existing artifact(s) needs to be deleted to delete the artifact file in the future + with service_exception_to_warning(): + self.service.file_delete( + experiment_id=self.experiment_id, + file_name=f"{artifact_name}.zip", + ) self._deleted_artifacts.remove(artifact_name) - if not self.service.local and self.verbose: - print( - "You can view the experiment online at " - f"https://quantum.ibm.com/experiments/{self.experiment_id}" - ) # handle children, but without additional prints if save_children: for data in self._child_data.values(): @@ -2490,7 +2404,7 @@ def child_data( def load( cls, experiment_id: str, - service: IBMExperimentService | None = None, + service: ExperimentService | None = None, provider: Provider | None = None, ) -> ExperimentData: """Load a saved experiment data from a database service. @@ -2498,12 +2412,7 @@ def load( Args: experiment_id: Experiment ID. service: the database service. - provider: an IBMProvider required for loading job 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`. + provider: provider to load job results from Returns: The loaded experiment data. @@ -2511,11 +2420,7 @@ def load( ExperimentDataError: If not service nor provider were given. """ if service is None: - if provider is None: - raise ExperimentDataError( - "Loading an experiment requires a valid Qiskit provider or experiment service." - ) - service = cls.get_service_from_provider(provider) + raise ExperimentDataError("Loading an experiment requires a valid experiment service.") data = service.experiment(experiment_id, json_decoder=cls._json_decoder) if service.experiment_has_file(experiment_id, cls._metadata_filename): metadata = service.file_download( @@ -2546,9 +2451,7 @@ def load( expdata._created_in_db = True child_data_ids = expdata.metadata.pop("child_data_ids", []) - child_data = [ - ExperimentData.load(child_id, service, provider) for child_id in 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 @@ -2929,7 +2832,7 @@ def service_exception_to_warning(): def _series_to_service_result( series: pd.Series, - service: IBMExperimentService, + service: ExperimentService, auto_save: bool, source: dict[str, Any] | None = None, ) -> AnalysisResult: @@ -2940,7 +2843,7 @@ def _series_to_service_result( Now :class:`.AnalysisResult` is only used to save data in the experiment service. All local operations must be done with :class:`.AnalysisResultTable` dataframe. ExperimentData._analysis_results are totally decoupled from - the model of IBM experiment service until this function is implicitly called. + the model of the experiment service until this function is implicitly called. Args: series: Pandas dataframe Series (a row of dataframe). @@ -2969,23 +2872,18 @@ def _series_to_service_result( result_data["_value"] = qe_result.value result_data["_extra"] = qe_result.extra - # IBM Experiment Service doesn't have data field for experiment and run time. + # DbAnalysisResultData doesn't have data field for experiment and run time. # These are added to extra field so that these data can be saved. result_data["_extra"]["experiment"] = qe_result.experiment result_data["_extra"]["run_time"] = qe_result.run_time - try: - quality = ResultQuality(str(qe_result.quality).upper()) - except ValueError: - quality = "unknown" - - experiment_service_payload = AnalysisResultDataclass( + experiment_service_payload = DbAnalysisResultData( result_id=qe_result.result_id, experiment_id=qe_result.experiment_id, result_type=qe_result.name, result_data=result_data, device_components=list(map(to_component, qe_result.device_components)), - quality=quality, + quality=ResultQuality.from_str(qe_result.quality), tags=qe_result.tags, backend_name=qe_result.backend, creation_datetime=qe_result.created_time, diff --git a/qiskit_experiments/framework/provider_interfaces.py b/qiskit_experiments/framework/provider_interfaces.py index 93e1b12b78..27bc085eb4 100644 --- a/qiskit_experiments/framework/provider_interfaces.py +++ b/qiskit_experiments/framework/provider_interfaces.py @@ -20,13 +20,19 @@ experiment results. """ +from __future__ import annotations + from enum import Enum, IntEnum -from typing import Protocol +from json import JSONEncoder, JSONDecoder +from typing import Protocol, TYPE_CHECKING from qiskit.result import Result from qiskit.primitives import PrimitiveResult from qiskit.providers import Backend, JobStatus +if TYPE_CHECKING: + from qiskit_experiments.database_service import DbExperimentData, DbAnalysisResultData + class BaseJob(Protocol): """Required interface definition of a job class as needed for experiment data""" @@ -79,45 +85,301 @@ def job(self, job_id: str) -> Job: raise NotImplementedError -class IBMProvider(BaseProvider, Protocol): - """Provider interface needed for supporting features like IBM Quantum +Provider = BaseProvider +"""Type alias of provider interface supported by Qiskit Experiments""" + + +class MeasReturnType(str, Enum): + """Backend return types for Qobj and backend.run jobs""" + + AVERAGE = "avg" + SINGLE = "single" + + +class MeasLevel(IntEnum): + """Measurement level types for legacy Qobj and Sampler jobs""" - This interface is the subset of - :class:`~qiskit_ibm_runtime.QiskitRuntimeService` needed for all features - of Qiskit Experiments. Another provider could implement this interface to - support these features as well. + RAW = 0 + KERNELED = 1 + CLASSIFIED = 2 + + +class ExperimentService(Protocol): + """Interface definition for experiment database service. + + This interface defines the methods needed by ExperimentData to interact + with an experiment database service, whether local or remote. + + .. note:: + + Some of the type signatures of the methods of this protocol could + change in future versions of Qiskit Experiments without a transition + period. """ - def active_account(self) -> dict[str, str] | None: - """Return the IBM Quantum account information currently in use + @property + def options(self) -> dict: + """Return service options dictionary. + + Returns: + Dictionary of service options + """ + raise NotImplementedError + + def create_or_update_experiment( + self, + data: DbExperimentData, + json_encoder: type[JSONEncoder] | None = None, + create: bool = True, + max_attempts: int = 3, + **kwargs, + ) -> DbExperimentData: + """Create or update an experiment in the database. + + Args: + data: Experiment data to save + json_encoder: Custom JSON encoder + create: Whether to attempt create first + max_attempts: Maximum number of attempts + **kwargs: Additional parameters + + Returns: + Experiment data of the experiment + """ + raise NotImplementedError + + def create_or_update_analysis_result( + self, + data: DbAnalysisResultData, + json_encoder: type[JSONEncoder] | None = None, + create: bool = True, + max_attempts: int = 3, + ) -> str: + """Create or update an analysis result in the database. - This method returns the current account information in a dictionary - format. It is used to copy the credentials for use with - ``qiskit-ibm-experiment`` without requiring specifying the credentials - for the provider and ``qiskit-ibm-experiment`` separately - It should include ``"url"`` and ``"token"`` as keys for the - authentication to work. + Args: + data: Analysis result data to save + json_encoder: Custom JSON encoder + create: Whether to attempt create first + max_attempts: Maximum number of attempts Returns: - A dictionary with information about the account currently in the session. + Analysis result ID """ raise NotImplementedError + def create_analysis_results( + self, + data: list, + blocking: bool = True, + max_workers: int = 100, + json_encoder: type[JSONEncoder] | None = None, + ): + """Create multiple analysis results in the database. -Provider = BaseProvider | IBMProvider -"""Union type of provider interfaces supported by Qiskit Experiments""" + Args: + data: List of analysis result data to save + blocking: Whether to wait for completion + max_workers: Maximum number of worker threads + json_encoder: Custom JSON encoder + Returns: + Status dictionary or handler object + """ + raise NotImplementedError -class MeasReturnType(str, Enum): - """Backend return types for Qobj and backend.run jobs""" + def experiment( + self, + experiment_id: str, + json_decoder: type[JSONDecoder] | None = None, + ) -> DbExperimentData: + """Retrieve a single experiment from the database. - AVERAGE = "avg" - SINGLE = "single" + Args: + experiment_id: Experiment ID + json_decoder: Custom JSON decoder + Returns: + Retrieved experiment data + """ + raise NotImplementedError -class MeasLevel(IntEnum): - """Measurement level types for legacy Qobj and Sampler jobs""" + def delete_experiment(self, experiment_id: str) -> None: + """Delete an experiment from the database. - RAW = 0 - KERNELED = 1 - CLASSIFIED = 2 + Args: + experiment_id: Experiment ID to delete + """ + raise NotImplementedError + + def analysis_result( + self, + result_id: str, + json_decoder: type[JSONDecoder] | None = None, + ) -> DbAnalysisResultData: + """Retrieve a single analysis result from the database. + + Args: + result_id: Analysis result ID + json_decoder: Custom JSON decoder + + Returns: + Retrieved analysis result data + """ + raise NotImplementedError + + def analysis_results( + self, + experiment_id: str | None = None, + limit: int | None = None, + json_decoder: type[JSONDecoder] | None = None, + **filters, + ) -> list: + """Query analysis results from the database. + + Args: + experiment_id: Filter by experiment ID + limit: Maximum number of results + json_decoder: Custom JSON decoder + **filters: Additional filter parameters + + Returns: + List of analysis results + """ + raise NotImplementedError + + def delete_analysis_result(self, result_id: str) -> None: + """Delete an analysis result from the database. + + Args: + result_id: Analysis result ID to delete + """ + raise NotImplementedError + + def create_or_update_figure( + self, + experiment_id: str, + figure: bytes | str, + figure_name: str | None = None, + create: bool = True, + max_attempts: int = 3, + ) -> tuple: + """Create or update a figure in the database. + + Args: + experiment_id: Experiment ID + figure: Figure data or file path + figure_name: Name for the figure + create: Whether to attempt create first + max_attempts: Maximum number of attempts + + Returns: + Tuple of (figure_name, size) + """ + raise NotImplementedError + + def create_figures( + self, + experiment_id: str, + figure_list: list, + blocking: bool = True, + max_workers: int = 100, + ): + """Create multiple figures in the database. + + Args: + experiment_id: Experiment ID + figure_list: List of (figure, name) tuples + blocking: Whether to wait for completion + max_workers: Maximum number of worker threads + + Returns: + Status dictionary or handler object + """ + raise NotImplementedError + + def figure( + self, + experiment_id: str, + figure_name: str, + file_name: str | None = None, + ) -> bytes | int: + """Retrieve a figure from the database. + + Args: + experiment_id: Experiment ID + figure_name: Name of the figure + file_name: Optional local file to save to + + Returns: + Figure bytes if file_name is None, otherwise size written + """ + raise NotImplementedError + + def delete_figure(self, experiment_id: str, figure_name: str) -> None: + """Delete a figure from the database. + + Args: + experiment_id: Experiment ID + figure_name: Name of the figure to delete + """ + raise NotImplementedError + + def files(self, experiment_id: str) -> dict: + """Retrieve the file list for an experiment. + + Args: + experiment_id: Experiment ID + + Returns: + Dictionary with file list metadata + """ + raise NotImplementedError + + def file_upload( + self, + experiment_id: str, + file_name: str, + file_data: dict | str | bytes, + json_encoder: type[JSONEncoder] | None = None, + ) -> None: + """Upload a file to the database. + + Args: + experiment_id: Experiment ID + file_name: Name for the file + file_data: File data (dict or JSON string or file bytes) + json_encoder: Custom JSON encoder + """ + raise NotImplementedError + + def file_delete( + self, + experiment_id: str, + file_name: str, + ): + """Delete a file from the database + + Args: + experiment_id: Experiment ID + file_name: Name for the file + """ + raise NotImplementedError + + def file_download( + self, + experiment_id: str, + file_name: str, + json_decoder: type[JSONDecoder] | None = None, + ) -> dict: + """Download a file from the database. + + Args: + experiment_id: Experiment ID + file_name: Name of the file + json_decoder: Custom JSON decoder + + Returns: + Deserialized file data + """ + raise NotImplementedError diff --git a/qiskit_experiments/test/__init__.py b/qiskit_experiments/test/__init__.py index 0571c2ca21..64a2c0ff4f 100644 --- a/qiskit_experiments/test/__init__.py +++ b/qiskit_experiments/test/__init__.py @@ -54,4 +54,3 @@ from .mock_iq_helpers import MockIQExperimentHelper, MockIQParallelExperimentHelper from .noisy_delay_aer_simulator import NoisyDelayAerBackend from .t2hahn_backend import T2HahnBackend -from .fake_service import FakeService diff --git a/qiskit_experiments/test/fake_service.py b/qiskit_experiments/test/fake_service.py deleted file mode 100644 index 3ec338826e..0000000000 --- a/qiskit_experiments/test/fake_service.py +++ /dev/null @@ -1,494 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2021. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -"""Fake service class for tests.""" - -from typing import Any -import json -from datetime import datetime, timedelta -import uuid - -import pandas as pd -from qiskit_ibm_experiment import AnalysisResultData - -from qiskit_experiments.test.fake_backend import FakeBackend -from qiskit_experiments.database_service.device_component import DeviceComponent -from qiskit_experiments.database_service.exceptions import ( - ExperimentEntryExists, - ExperimentEntryNotFound, -) - - -class FakeService: - """ - This extremely simple database is designated for testing and as a playground for developers. - It does not support multi-threading. - It is not guaranteed to perform well for a large amount of data. - It implements most of the methods of `DatabaseService`. - """ - - def __init__(self): - self.exps = pd.DataFrame( - columns=[ - "experiment_type", - "backend_name", - "metadata", - "experiment_id", - "parent_id", - "job_ids", - "tags", - "notes", - "share_level", - "start_datetime", - "device_components", - "figure_names", - "backend", - ] - ) - self.results = pd.DataFrame( - columns=[ - "experiment_id", - "result_data", - "result_type", - "device_components", - "tags", - "quality", - "verified", - "result_id", - "chisq", - "creation_datetime", - "backend_name", - ] - ) - - def create_experiment( - self, - experiment_type: str, - backend_name: str, - metadata: dict | None = None, - experiment_id: str | None = None, - parent_id: str | None = None, - job_ids: list[str] | None = None, - tags: list[str] | None = None, - notes: str | None = None, - json_encoder: type[json.JSONEncoder] = json.JSONEncoder, - **kwargs: Any, - ) -> str: - """Creates a new experiment""" - # currently not using json_encoder; this should be changed - # pylint: disable = unused-argument - if experiment_id is None: - experiment_id = uuid.uuid4() - - if experiment_id in self.exps.experiment_id.values: - raise ExperimentEntryExists("Cannot add experiment with existing id") - - # Clarifications about some of the columns: - # share_level - not a parameter of `DatabaseService.create_experiment` but a parameter of - # `IBMExperimentService.create_experiment`. It must be supported because it is used - # in `DbExperimentData`. - # device_components - the user specifies the device components when adding a result - # (this is not a local decision of the fake service but the interface of DatabaseService - # and IBMExperimentService). The components of the different results of the same - # experiment are aggregated here in the device_components column. - # start_datetime - not a parameter of `DatabaseService.create_experiment` but a parameter of - # `IBMExperimentService.create_experiment`. Since `DbExperimentData` does not set it - # via kwargs (as it does with share_level), the user cannot control the time and the - # service alone decides about it. Here we've chosen to set a unique time for each - # experiment, with the first experiment dated to midnight of January 1st, 2022, the - # second experiment an hour later, etc. - # figure_names - the fake service currently does not support figures. The column - # (degenerated to []) is required to prevent a flaw in the work with DbExperimentData. - # backend - the query methods `experiment` and `experiments` are supposed to return an - # an instantiated backend object, and not only the backend name. We assume that the fake - # service works with the fake backend (class FakeBackend). - row = pd.DataFrame( - [ - { - "experiment_type": experiment_type, - "experiment_id": experiment_id, - "parent_id": parent_id, - "backend_name": backend_name, - "metadata": metadata, - "job_ids": job_ids, - "tags": tags, - "notes": notes, - "share_level": kwargs.get("share_level", None), - "device_components": [], - "start_datetime": datetime(2022, 1, 1) + timedelta(hours=len(self.exps)), - "figure_names": [], - "backend": FakeBackend(backend_name=backend_name), - } - ], - columns=self.exps.columns, - ) - if len(self.exps) > 0: - self.exps = pd.concat( - [ - self.exps, - row, - ], - ignore_index=True, - ) - else: - # Avoid the FutureWarning on concatenating empty DataFrames - # introduced in https://github.com/pandas-dev/pandas/pull/52532 - self.exps = row - - return experiment_id - - def update_experiment( - self, - experiment_id: str, - metadata: dict | None = None, - job_ids: list[str] | None = None, - notes: str | None = None, - tags: list[str] | None = None, - **kwargs: Any, - ) -> None: - """Updates an existing experiment""" - if experiment_id not in self.exps.experiment_id.values: - raise ExperimentEntryNotFound("Attempt to update a non-existing experiment") - - row = self.exps.experiment_id == experiment_id - if metadata is not None: - self.exps.loc[row, "metadata"] = metadata - if job_ids is not None: - self.exps.loc[row, "job_ids"] = job_ids - if tags is not None: - self.exps.loc[row, "tags"] = tags - if notes is not None: - self.exps.loc[row, "notes"] = notes - - for field_name in ["share_level", "parent_id"]: - if field_name in kwargs: - self.exps.loc[row, field_name] = kwargs[field_name] - - def experiment( - self, experiment_id: str, json_decoder: type[json.JSONDecoder] = json.JSONDecoder - ) -> dict: - """Returns an experiment by experiment_id""" - # pylint: disable = unused-argument - if experiment_id not in self.exps.experiment_id.values: - raise ExperimentEntryNotFound("Experiment does not exist") - - return self.exps.loc[self.exps.experiment_id == experiment_id].to_dict("records")[0] - - def experiments( - self, - limit: int | None = 10, - json_decoder: type[json.JSONDecoder] = json.JSONDecoder, - device_components: str | DeviceComponent | None = None, - experiment_type: str | None = None, - backend_name: str | None = None, - tags: list[str] | None = None, - parent_id: str | None = None, - tags_operator: str | None = "OR", - **filters: Any, - ) -> list[dict]: - """Returns a list of experiments filtered by given criteria""" - # pylint: disable = unused-argument - df = self.exps - - if experiment_type is not None: - df = df.loc[df.experiment_type == experiment_type] - - if backend_name is not None: - df = df.loc[df.backend_name == backend_name] - - # Note a bug in the interface for all services: - # It is impossible to filter by experiments whose parent id is None - # (i.e., root experiments) - if parent_id is not None: - df = df.loc[df.parent_id == parent_id] - - # Waiting for consistency between provider service and qiskit-experiments service, - # currently they have different types for `device_components` - if device_components is not None: - raise ValueError( - "The fake service currently does not support filtering on device components" - ) - - if tags is not None: - if tags_operator == "OR": - df = df.loc[df.tags.apply(lambda dftags: any(x in dftags for x in tags))] - elif tags_operator == "AND": - df = df.loc[df.tags.apply(lambda dftags: all(x in dftags for x in tags))] - else: - raise ValueError("Unrecognized tags operator") - - # These are parameters of IBMExperimentService.experiments - if "start_datetime_before" in filters: - df = df.loc[df.start_datetime <= filters["start_datetime_before"]] - if "start_datetime_after" in filters: - df = df.loc[df.start_datetime >= filters["start_datetime_after"]] - - # This is a parameter of IBMExperimentService.experiments - sort_by = filters.get("sort_by", "start_datetime:desc") - - if not isinstance(sort_by, list): - sort_by = [sort_by] - - # TODO: support also experiment_type - if len(sort_by) != 1: - raise ValueError("The fake service currently supports only sorting by start_datetime") - - sortby_split = sort_by[0].split(":") - # TODO: support also experiment_type - if ( - len(sortby_split) != 2 - or sortby_split[0] != "start_datetime" - or (sortby_split[1] != "asc" and sortby_split[1] != "desc") - ): - raise ValueError( - "The fake service currently supports only sorting by start_datetime, which can be " - "either asc or desc" - ) - - df = df.sort_values( - ["start_datetime", "experiment_id"], ascending=[(sortby_split[1] == "asc"), True] - ) - - df = df.iloc[:limit] - - return df.to_dict("records") - - def delete_experiment(self, experiment_id: str) -> None: - """Deletes an experiment""" - if experiment_id not in self.exps.experiment_id.values: - return - - index = self.exps[self.exps.experiment_id == experiment_id].index - self.exps.drop(index, inplace=True) - - def create_analysis_result( - self, - experiment_id: str, - result_data: dict, - result_type: str, - device_components: str | DeviceComponent | None = None, - tags: list[str] | None = None, - quality: str | None = None, - verified: bool = False, - result_id: str | None = None, - json_encoder: type[json.JSONEncoder] = json.JSONEncoder, - **kwargs: Any, - ) -> str: - """Creates an analysis result""" - # pylint: disable = unused-argument - if result_id is None: - result_id = uuid.uuid4() - - if result_id in self.results.result_id.values: - raise ExperimentEntryExists("Cannot add analysis result with existing id") - - # Clarifications about some of the columns: - # backend_name - taken from the experiment. - # creation_datetime - start_datetime - not a parameter of - # `DatabaseService.create_analysis_result` but a parameter of - # `IBMExperimentService.create_analysis_result`. Since `DbExperimentData` does not set it - # via kwargs (as it does with chisq), the user cannot control the time and the service - # alone decides about it. Here we've chosen to set the start date of the experiment. - row = pd.DataFrame( - [ - { - "result_data": result_data, - "result_id": result_id, - "result_type": result_type, - "device_components": device_components, - "experiment_id": experiment_id, - "quality": quality, - "verified": verified, - "tags": tags, - "backend_name": self.exps.loc[self.exps.experiment_id == experiment_id] - .iloc[0] - .backend_name, - "chisq": kwargs.get("chisq", None), - "creation_datetime": self.exps.loc[self.exps.experiment_id == experiment_id] - .iloc[0] - .start_datetime, - } - ] - ) - if len(self.results) > 0: - self.results = pd.concat( - [ - self.results, - row, - ], - ignore_index=True, - ) - else: - # Avoid the FutureWarning on concatenating empty DataFrames - # introduced in https://github.com/pandas-dev/pandas/pull/52532 - self.results = row - - # a helper method for updating the experiment's device components, see usage below - def add_new_components(expcomps): - for dc in device_components: - if dc not in expcomps: - expcomps.append(dc) - - # update the experiment's device components - self.exps.loc[self.exps.experiment_id == experiment_id, "device_components"].apply( - add_new_components - ) - - return result_id - - def update_analysis_result( - self, - result_id: str, - result_data: dict | None = None, - tags: list[str] | None = None, - quality: str | None = None, - verified: bool = None, - **kwargs: Any, - ) -> None: - """Updates an analysis result""" - if result_id not in self.results.result_id.values: - raise ExperimentEntryNotFound("Attempt to update a non-existing analysis result") - - row = self.results.result_id == result_id - if result_data is not None: - self.results.loc[row, "result_data"] = result_data - if tags is not None: - self.results.loc[row, "tags"] = tags - if quality is not None: - self.results.loc[row, "quality"] = quality - if verified is not None: - self.results.loc[row, "verified"] = verified - if "chisq" in kwargs: - self.results.loc[row, "chisq"] = kwargs["chisq"] - - def analysis_result( - self, result_id: str, json_decoder: type[json.JSONDecoder] = json.JSONDecoder - ) -> dict: - """Gets an analysis result by result_id""" - # pylint: disable = unused-argument - if result_id not in self.results.result_id.values: - raise ExperimentEntryNotFound("Analysis result does not exist") - - # The `experiment` method implements special handling of the backend, we skip it here. - # It's a bit strange, so, if not required by `DbExperimentData` then we'd better skip. - return self.results.loc[self.results.result_id == result_id].to_dict("records")[0] - - def analysis_results( - self, - limit: int | None = 10, - json_decoder: type[json.JSONDecoder] = json.JSONDecoder, - device_components: str | DeviceComponent | None = None, - experiment_id: str | None = None, - result_type: str | None = None, - backend_name: str | None = None, - quality: str | None = None, - verified: bool | None = None, - tags: list[str] | None = None, - tags_operator: str | None = "OR", - **filters: Any, - ) -> list[AnalysisResultData]: - """Returns a list of analysis results filtered by the given criteria""" - # pylint: disable = unused-argument - df = self.results - - # TODO: skipping device components for now until we consolidate more with the provider service - # (in the qiskit-experiments service there is no operator for device components, - # so the specification for filtering is not clearly defined) - - if experiment_id is not None: - df = df.loc[df.experiment_id == experiment_id] - if result_type is not None: - df = df.loc[df.result_type == result_type] - if backend_name is not None: - df = df.loc[df.backend_name == backend_name] - if quality is not None: - df = df.loc[df.quality == quality] - if verified is not None: - df = df.loc[df.verified == verified] - - if tags is not None: - if tags_operator == "OR": - df = df.loc[df.tags.apply(lambda dftags: any(x in dftags for x in tags))] - elif tags_operator == "AND": - df = df.loc[df.tags.apply(lambda dftags: all(x in dftags for x in tags))] - else: - raise ValueError("Unrecognized tags operator") - - # This is a parameter of IBMExperimentService.experiments - sort_by = filters.get("sort_by", "creation_datetime:desc") - - if not isinstance(sort_by, list): - sort_by = [sort_by] - - # TODO: support also device components and result type - if len(sort_by) != 1: - raise ValueError( - "The fake service currently supports only sorting by creation_datetime" - ) - - sortby_split = sort_by[0].split(":") - # TODO: support also device components and result type - if ( - len(sortby_split) != 2 - or sortby_split[0] != "creation_datetime" - or (sortby_split[1] != "asc" and sortby_split[1] != "desc") - ): - raise ValueError( - "The fake service currently supports only sorting by creation_datetime, " - "which can be either asc or desc" - ) - - df = df.sort_values( - ["creation_datetime", "result_id"], ascending=[(sortby_split[1] == "asc"), True] - ) - - df = df.iloc[:limit] - return [AnalysisResultData(**res) for res in df.to_dict("records")] - - def delete_analysis_result(self, result_id: str) -> None: - """Deletes an analysis result""" - if result_id not in self.results.result_id.values: - return - - index = self.results[self.results.result_id == result_id].index - self.results.drop(index, inplace=True) - - def create_figure( - self, experiment_id: str, figure: str | bytes, figure_name: str | None - ) -> tuple[str, int]: - """Creates a figure""" - pass - - def update_figure( - self, experiment_id: str, figure: str | bytes, figure_name: str - ) -> tuple[str, int]: - """Updates a figure""" - pass - - def figure( - self, experiment_id: str, figure_name: str, file_name: str | None = None - ) -> int | bytes: - """Returns a figure by experiment id and figure name""" - pass - - def delete_figure( - self, - experiment_id: str, - figure_name: str, - ) -> None: - """Deletes a figure""" - pass - - @property - def preferences(self) -> dict: - """Returns the db service preferences""" - return {"auto_save": False} diff --git a/qiskit_experiments/test/utils.py b/qiskit_experiments/test/utils.py index 61773c3ae4..49b5c6762d 100644 --- a/qiskit_experiments/test/utils.py +++ b/qiskit_experiments/test/utils.py @@ -12,8 +12,12 @@ """Test utility functions.""" +from __future__ import annotations + import uuid +from collections.abc import Callable from datetime import datetime, timezone +from typing import TYPE_CHECKING from qiskit.providers.job import JobV1 as Job from qiskit.providers.jobstatus import JobStatus @@ -21,10 +25,37 @@ from qiskit.result import Result +if TYPE_CHECKING: + from qiskit_experiments.framework import Job as JobLike + + +class FakeProvider: + """Dummy Provider class for test purposes only""" + + def __init__(self): + self._jobs: dict[str, JobLike] = {} + + def add_job(self, job: JobLike): + """Add job to provider""" + self._jobs[job.job_id()] = job + + def job(self, job_id: str) -> JobLike: + """Retrieve job by job ID""" + return self._jobs[job_id] + + class FakeJob(Job): """Fake job.""" - def __init__(self, backend: Backend, result: Result | None = None): + def __init__( + self, + backend: Backend, + result: Result | None = None, + status: JobStatus | None = None, + cancel_callback: Callable | None = None, + result_callback: Callable | None = None, + status_callback: Callable | None = None, + ): """Initialize FakeJob.""" if result: job_id = result.job_id @@ -32,11 +63,24 @@ def __init__(self, backend: Backend, result: Result | None = None): job_id = uuid.uuid4().hex super().__init__(backend, job_id) self._result = result + self._status = status + + self._cancel_callback = cancel_callback + self._result_callback = result_callback + self._status_callback = status_callback def result(self): """Return job result.""" + if self._result_callback: + return self._result_callback() return self._result + def cancel(self): + """Cancel the job""" + self._status = JobStatus.CANCELLED + if self._cancel_callback: + self._cancel_callback() + def submit(self): """Submit the job to the backend for execution.""" pass @@ -48,6 +92,11 @@ def time_per_step() -> dict[str, datetime]: def status(self) -> JobStatus: """Return the status of the job, among the values of ``JobStatus``.""" + if self._status_callback: + return self._status_callback() + + if self._status is not None: + return self._status if self._result: return JobStatus.DONE return JobStatus.RUNNING diff --git a/releasenotes/notes/adopt-qiskit-ibm-experiment-9bc997dd31535ffc.yaml b/releasenotes/notes/adopt-qiskit-ibm-experiment-9bc997dd31535ffc.yaml new file mode 100644 index 0000000000..e4e5fc84ad --- /dev/null +++ b/releasenotes/notes/adopt-qiskit-ibm-experiment-9bc997dd31535ffc.yaml @@ -0,0 +1,34 @@ +--- +upgrade: + - | + The dependency on ``qiskit-ibm-experiment`` has been removed following that + project's discontinuation. Its classes related to storing experiment + results in a database (referred to in the documentation as an experiment + service) have been moved into Qiskit Experiments as + :class:`.DbExperimentData`, :class:`.DbAnalysisResultData`, and + :class:`.ResultQuality`. These classes are used internally by + :class:`.ExperimentData` and :class:`.AnalysisResult`, so the move should + be transparent to the user in most cases. Additionally, ``pyyaml`` was + added an optional dependency for the new :class:`.LocalExperimentService`. + ``pyyaml`` was previously pulled in as a dependency of + ``qiskit-ibm-experiment``. + - | + The support within :class:`.ExperimentData` for looking up an experiment + service from an experiment's backend or provider has been removed. This + support was only relevant for the IBM experiment service which was + discontinued. + - | + The ``IBMProvider`` interface class was removed. This class was only used + for type annotations to indicate where a provider could be passed that + would allow looking up an experiment service. +developer: + - | + :class:`.LocalExperimentService` has been added as a reference + implementation of a service for saving and loading experiment results. As + noted in its documentation, it was written for testing purposes and is not + currently suitable for storing a large amount of results. +other: + - | + The `howto `_ on working with the cloud experiment + service has been reworked to demonstrate using + :class:`.LocalExperimentService` instead. diff --git a/releasenotes/notes/lazy-imports-730268f4cd8763dc.yaml b/releasenotes/notes/lazy-imports-730268f4cd8763dc.yaml index f6d354dbeb..0022ee55a9 100644 --- a/releasenotes/notes/lazy-imports-730268f4cd8763dc.yaml +++ b/releasenotes/notes/lazy-imports-730268f4cd8763dc.yaml @@ -3,7 +3,7 @@ features: - | Importing of optional dependencies has been made delayed until first use. In particular, ``cvxpy`` is now not imported until a tomography fitter that - uses it is used. Additionally, ``qiskit_ibm_runtime`` is not imported an + uses it is used. Additionally, ``qiskit_ibm_runtime`` is not imported until an experiment is run without being passed a sampler argument. Previously, both packages were imported when ``qiskit_experiments`` was imported (``cvxpy`` is optional, but it was always imported if it was available). diff --git a/test/database_service/test_db_analysis_result.py b/test/database_service/test_db_analysis_result.py index 8be2b2368b..79d1beeb1e 100644 --- a/test/database_service/test_db_analysis_result.py +++ b/test/database_service/test_db_analysis_result.py @@ -14,7 +14,6 @@ """Test AnalysisResult.""" from test.base import QiskitExperimentsTestCase -from unittest import mock import json import math @@ -22,10 +21,16 @@ import numpy as np import uncertainties -from qiskit_ibm_experiment import IBMExperimentService, ExperimentData from qiskit_experiments.framework import AnalysisResult -from qiskit_experiments.database_service.device_component import Qubit, Resonator, to_component -from qiskit_experiments.database_service.exceptions import ExperimentDataError +from qiskit_experiments.database_service import ( + DbExperimentData, + ExperimentDataError, + LocalExperimentService, + Qubit, + Resonator, + ResultQuality, + to_component, +) @ddt @@ -39,28 +44,31 @@ def test_analysis_result_attributes(self): "device_components": [Qubit(1), Qubit(2)], "experiment_id": "1234", "result_id": "5678", - "quality": "Good", + "quality": "good", "verified": False, } result = AnalysisResult(value={"foo": "bar"}, tags=["tag1", "tag2"], **attrs) self.assertEqual({"foo": "bar"}, result.value) self.assertEqual(["tag1", "tag2"], result.tags) for key, val in attrs.items(): + if key == "quality": + val = ResultQuality.from_str(val) self.assertEqual(val, getattr(result, key)) def test_save(self): """Test saving analysis result.""" - mock_service = mock.create_autospec(IBMExperimentService) + service = LocalExperimentService() result = self._new_analysis_result() - result.service = mock_service + result.service = service + service.create_or_update_experiment(DbExperimentData(experiment_id=result.experiment_id)) result.save() - mock_service.create_or_update_analysis_result.assert_called_once() + service.analysis_result(result.result_id) def test_load(self): """Test loading analysis result.""" - service = IBMExperimentService(local=True, local_save=False) + service = LocalExperimentService() result = self._new_analysis_result() - service.create_experiment(ExperimentData(experiment_id=result.experiment_id)) + service.create_or_update_experiment(DbExperimentData(experiment_id=result.experiment_id)) result.service = service result.save() loaded_result = AnalysisResult.load(result_id=result.result_id, service=service) @@ -69,40 +77,37 @@ def test_load(self): def test_auto_save(self): """Test auto saving.""" - mock_service = mock.create_autospec(IBMExperimentService) - result = self._new_analysis_result(service=mock_service) + service = LocalExperimentService() + result = self._new_analysis_result(service=service) + service.create_or_update_experiment(DbExperimentData(experiment_id=result.experiment_id)) result.auto_save = True - mock_service.reset_mock() # since setting auto_save = True initiated save - - subtests = [ - # update function, update parameters, service called - (setattr, (result, "tags", ["foo"])), - (setattr, (result, "value", {"foo": "bar"})), - (setattr, (result, "quality", "GOOD")), - (setattr, (result, "verified", True)), - ] - - for func, params in subtests: - with self.subTest(func=func): - func(*params) - mock_service.create_or_update_analysis_result.assert_called_once() - mock_service.reset_mock() + + result.tags = ["foo"] + result.value = {"foo": "bar"} + result.quality = ResultQuality.GOOD + result.verified = True + + loaded_result = AnalysisResult.load(result.result_id, service) + self.assertEqual(result.tags, loaded_result.tags) + self.assertEqual(result.value, loaded_result.value) + self.assertEqual(result.quality, loaded_result.quality) + self.assertEqual(result.verified, loaded_result.verified) def test_set_service_init(self): """Test setting service in init.""" - mock_service = mock.create_autospec(IBMExperimentService) - result = self._new_analysis_result(service=mock_service) - self.assertEqual(mock_service, result.service) + service = LocalExperimentService() + result = self._new_analysis_result(service=service) + self.assertEqual(service, result.service) def test_set_service_direct(self): """Test setting service directly.""" - mock_service = mock.create_autospec(IBMExperimentService) + service = LocalExperimentService() result = self._new_analysis_result() - result.service = mock_service - self.assertEqual(mock_service, result.service) + result.service = service + self.assertEqual(service, result.service) with self.assertRaises(ExperimentDataError): - result.service = mock_service + result.service = service def test_set_data(self): """Test setting data.""" diff --git a/test/database_service/test_db_experiment_data.py b/test/database_service/test_db_experiment_data.py index 4632d9f338..8034ddda97 100644 --- a/test/database_service/test_db_experiment_data.py +++ b/test/database_service/test_db_experiment_data.py @@ -15,39 +15,40 @@ """Test ExperimentData.""" from test.base import QiskitExperimentsTestCase from test.fake_experiment import FakeExperiment -import os -from unittest import mock import copy -from random import randrange -import time -import threading import json +import os import re +import threading +import time import uuid from datetime import datetime, timedelta +from random import randrange import matplotlib.pyplot as plt import numpy as np from qiskit_ibm_runtime.fake_provider import FakeMelbourneV2 from qiskit.result import Result -from qiskit.providers import JobV1 as Job from qiskit.providers import JobStatus -from qiskit.providers.backend import Backend -from qiskit_ibm_experiment import IBMExperimentService -from qiskit_experiments.framework import ExperimentData, BackendData, ArtifactData - -from qiskit_experiments.database_service.exceptions import ( +from qiskit_experiments.framework import ( + ArtifactData, + ExperimentData, + BackendData, +) +from qiskit_experiments.database_service import ( ExperimentDataError, ExperimentEntryNotFound, + LocalExperimentService, + Qubit, ) -from qiskit_experiments.database_service.device_component import Qubit from qiskit_experiments.framework.experiment_data import ( AnalysisStatus, ExperimentStatus, ) from qiskit_experiments.framework.matplotlib import get_non_gui_ax from qiskit_experiments.test.fake_backend import FakeBackend +from qiskit_experiments.test.utils import FakeJob class TestDbExperimentData(QiskitExperimentsTestCase): @@ -57,15 +58,6 @@ def setUp(self): super().setUp() self.backend = FakeMelbourneV2() - def generate_mock_job(self): - """Helper method to generate a mock job.""" - job = mock.create_autospec(Job, instance=True) - # mock a backend without provider - backend = mock.create_autospec(Backend, instance=True) - backend.provider = None - job.backend.return_value = backend - return job - def test_db_experiment_data_attributes(self): """Test DB experiment data attributes.""" attrs = { @@ -128,14 +120,13 @@ def test_add_data_result_metadata(self): def test_add_data_job(self): """Test add job data.""" - a_job = self.generate_mock_job() - a_job.result.return_value = self._get_job_result(3) + a_job = FakeJob(self.backend, self._get_job_result(3, job_id="0")) num_circs = 3 jobs = [] for _ in range(2): - job = self.generate_mock_job() - job.result.return_value = self._get_job_result(2, label_from=num_circs) - job.status.return_value = JobStatus.DONE + job = FakeJob( + self.backend, self._get_job_result(2, label_from=num_circs, job_id=str(num_circs)) + ) jobs.append(job) num_circs = num_circs + 2 @@ -170,9 +161,7 @@ def _callback(_exp_data): nonlocal called_back called_back = True - a_job = self.generate_mock_job() - a_job.result.return_value = self._get_job_result(2) - a_job.status.return_value = JobStatus.DONE + a_job = FakeJob(self.backend, self._get_job_result(2)) called_back = False exp_data = ExperimentData(backend=self.backend, experiment_type="qiskit_test") @@ -224,10 +213,7 @@ def _callback(_exp_data, **kwargs): nonlocal called_back called_back = True - a_job = self.generate_mock_job() - a_job.backend.return_value = mock.create_autospec(Backend, instance=True) - a_job.result.return_value = self._get_job_result(2) - a_job.status.return_value = JobStatus.DONE + a_job = FakeJob(self.backend, self._get_job_result(2)) called_back = False callback_kwargs = "foo" @@ -243,9 +229,7 @@ def test_add_data_pending_post_processing(self): def _callback(_exp_data, **kwargs): kwargs["event"].wait(timeout=3) - a_job = self.generate_mock_job() - a_job.result.return_value = self._get_job_result(2) - a_job.status.return_value = JobStatus.DONE + a_job = FakeJob(self.backend, self._get_job_result(2)) event = threading.Event() self.addCleanup(event.set) @@ -296,16 +280,15 @@ def test_add_figure_plot(self): """Test adding a matplotlib figure.""" figure, ax = plt.subplots() ax.plot([1, 2, 3]) + fig_name = "figure.svg" - service = self._set_mock_service() + service = LocalExperimentService() exp_data = ExperimentData( backend=self.backend, experiment_type="qiskit_test", service=service ) - exp_data.add_figures(figure, save_figure=True) + exp_data.add_figures(figure, fig_name, save_figure=True) self.assertEqual(figure, exp_data.figure(0).figure) - service.create_or_update_figure.assert_called_once() - _, kwargs = service.create_or_update_figure.call_args - self.assertIsInstance(kwargs["figure"], bytes) + self.assertIsInstance(service.figure(exp_data.experiment_id, fig_name), bytes) def test_add_figures(self): """Test adding multiple new figures.""" @@ -359,15 +342,14 @@ def test_add_figure_overwrite(self): def test_add_figure_save(self): """Test saving a figure in the database.""" hello_bytes = str.encode("hello world") - service = self._set_mock_service() + fig_name = "hello.svg" + service = LocalExperimentService() exp_data = ExperimentData( backend=self.backend, experiment_type="qiskit_test", service=service ) - exp_data.add_figures(hello_bytes, save_figure=True) - service.create_or_update_figure.assert_called_once() - _, kwargs = service.create_or_update_figure.call_args - self.assertEqual(kwargs["figure"], hello_bytes) - self.assertEqual(kwargs["experiment_id"], exp_data.experiment_id) + exp_data.add_figures(hello_bytes, fig_name, save_figure=True) + loaded = service.figure(exp_data.experiment_id, fig_name) + self.assertEqual(hello_bytes, loaded) def test_add_figure_metadata(self): hello_bytes = str.encode("hello world") @@ -460,14 +442,14 @@ def test_delayed_backend(self): exp_data = ExperimentData(experiment_type="qiskit_test") self.assertIsNone(exp_data.backend) exp_data.save_metadata() - a_job = self.generate_mock_job() + a_job = FakeJob(self.backend, self._get_job_result(2)) exp_data.add_jobs(a_job) self.assertIsNotNone(exp_data.backend) def test_different_backend(self): """Test setting a different backend.""" exp_data = ExperimentData(backend=self.backend, experiment_type="qiskit_test") - a_job = self.generate_mock_job() + a_job = FakeJob(FakeBackend(), self._get_job_result(2)) self.assertNotEqual(exp_data.backend, a_job.backend()) with self.assertLogs("qiskit_experiments", "WARNING"): exp_data.add_jobs(a_job) @@ -524,99 +506,89 @@ def test_delete_analysis_result(self): def test_save_metadata(self): """Test saving experiment metadata.""" exp_data = ExperimentData(backend=self.backend, experiment_type="qiskit_test") - service = mock.create_autospec(IBMExperimentService, instance=True) + service = LocalExperimentService() exp_data.service = service exp_data.save_metadata() - service.create_or_update_experiment.assert_called_once() - data = service.create_or_update_experiment.call_args[0][0] - self.assertEqual(exp_data.experiment_id, data.experiment_id) + loaded = service.experiment(exp_data.experiment_id) + self.assertEqual(exp_data.experiment_id, loaded.experiment_id) def test_save(self): """Test saving all experiment related data.""" exp_data = ExperimentData(backend=self.backend, experiment_type="qiskit_test") - service = mock.create_autospec(IBMExperimentService, instance=True) + service = LocalExperimentService() exp_data.add_figures(str.encode("hello world")) exp_data.add_analysis_results(result_id=str(uuid.uuid4())) exp_data.service = service exp_data.save() - service.create_or_update_experiment.assert_called_once() - service.create_figures.assert_called_once() - service.create_analysis_results.assert_called_once() + loaded = service.experiment(exp_data.experiment_id) + self.assertEqual(exp_data.experiment_id, loaded.experiment_id) + self.assertEqual(len(loaded.figure_names), 1) + results = service.analysis_results(experiment_id=exp_data.experiment_id) + self.assertEqual(len(results), 1) def test_save_delete(self): """Test saving all deletion.""" exp_data = ExperimentData(backend=self.backend, experiment_type="qiskit_test") - service = mock.create_autospec(IBMExperimentService, instance=True) + service = LocalExperimentService() + exp_data.service = service exp_data.add_figures(str.encode("hello world")) exp_data.add_analysis_results(result_id=str(uuid.uuid4())) + exp_data.save() + exp_data.delete_analysis_result(0) exp_data.delete_figure(0) - exp_data.service = service - exp_data.save() - service.create_or_update_experiment.assert_called_once() - service.delete_figure.assert_called_once() - service.delete_analysis_result.assert_called_once() + + loaded = service.experiment(exp_data.experiment_id) + self.assertEqual(len(loaded.figure_names), 0) + results = service.analysis_results(experiment_id=exp_data.experiment_id) + self.assertEqual(len(results), 0) def test_set_service_direct(self): """Test setting service directly.""" exp_data = ExperimentData(experiment_type="qiskit_test") self.assertIsNone(exp_data.service) - mock_service = mock.MagicMock() - exp_data.service = mock_service - self.assertEqual(mock_service, exp_data.service) + service = LocalExperimentService() + exp_data.service = service + self.assertEqual(service, exp_data.service) with self.assertRaises(ExperimentDataError): - exp_data.service = mock_service + exp_data.service = service def test_auto_save(self): """Test auto save.""" - service = self._set_mock_service() + service = LocalExperimentService() exp_data = ExperimentData( backend=self.backend, experiment_type="qiskit_test", service=service ) exp_data.auto_save = True - mock_result = mock.MagicMock() - mock_result.result_id = str(uuid.uuid4()) - subtests = [ - # update function, update parameters, service called - ( - exp_data.add_analysis_results, - (), - {"result_id": str(uuid.uuid4())}, - service.create_or_update_analysis_result, - ), - ( - exp_data.add_figures, - (str.encode("hello world"),), - {}, - service.create_or_update_figure, - ), - (exp_data.delete_figure, (0,), {}, service.delete_figure), - (exp_data.delete_analysis_result, (0,), {}, service.delete_analysis_result), - (setattr, (exp_data, "tags", ["foo"]), {}, service.create_or_update_experiment), - (setattr, (exp_data, "notes", "foo"), {}, service.create_or_update_experiment), - (setattr, (exp_data, "share_level", "hub"), {}, service.create_or_update_experiment), - ] + exp_data.add_figures(str.encode("hello world")) + exp_data.add_analysis_results(result_id=str(uuid.uuid4())) + + exp_data.delete_analysis_result(0) + exp_data.delete_figure(0) + + exp_data.tags = ["foo"] + exp_data.notes = "foo" - for func, params, kwargs, called in subtests: - with self.subTest(func=func): - func(*params, **kwargs) - if called: - called.assert_called_once() - service.reset_mock() + loaded = service.experiment(exp_data.experiment_id) + self.assertEqual(len(loaded.figure_names), 0) + results = service.analysis_results(experiment_id=exp_data.experiment_id) + self.assertEqual(len(results), 0) + self.assertEqual(loaded.tags, ["foo"]) + self.assertEqual(loaded.notes, "foo") def test_status_job_pending(self): """Test experiment status when job is pending.""" - job1 = self.generate_mock_job() - job1.result.return_value = self._get_job_result(3) - job1.status.return_value = JobStatus.DONE + job1 = FakeJob(self.backend, self._get_job_result(3)) event = threading.Event() - job2 = self.generate_mock_job() - job2.result = lambda *args, **kwargs: event.wait(timeout=15) - job2.status = lambda: JobStatus.CANCELLED if event.is_set() else JobStatus.RUNNING + job2 = FakeJob( + self.backend, + result_callback=lambda: event.wait(timeout=15), + status_callback=lambda: JobStatus.CANCELLED if event.is_set() else JobStatus.RUNNING, + ) self.addCleanup(event.set) exp_data = ExperimentData(experiment_type="qiskit_test") @@ -634,24 +606,17 @@ def test_status_job_pending(self): def test_status_job_error(self): """Test experiment status when job failed.""" - job1 = self.generate_mock_job() - job1.result.return_value = self._get_job_result(3) - job1.status.return_value = JobStatus.DONE + job1 = FakeJob(self.backend, self._get_job_result(3)) - job2 = self.generate_mock_job() - job2.status.return_value = JobStatus.ERROR + job2 = FakeJob(self.backend, status=JobStatus.ERROR) exp_data = ExperimentData(experiment_type="qiskit_test") - with self.assertLogs(logger="qiskit_experiments.framework", level="WARN") as cm: - exp_data.add_jobs([job1, job2]) - self.assertIn("Adding a job from a backend", ",".join(cm.output)) + exp_data.add_jobs([job1, job2]) self.assertEqual(ExperimentStatus.ERROR, exp_data.status()) def test_status_post_processing(self): """Test experiment status during post processing.""" - job = self.generate_mock_job() - job.result.return_value = self._get_job_result(3) - job.status.return_value = JobStatus.DONE + job = FakeJob(self.backend, self._get_job_result(3)) event = threading.Event() self.addCleanup(event.set) @@ -664,9 +629,7 @@ def test_status_post_processing(self): def test_status_cancelled_analysis(self): """Test experiment status during post processing.""" - job = self.generate_mock_job() - job.result.return_value = self._get_job_result(3) - job.status.return_value = JobStatus.DONE + job = FakeJob(self.backend, self._get_job_result(3)) event = threading.Event() self.addCleanup(event.set) @@ -686,9 +649,7 @@ def test_status_post_processing_error(self): def _post_processing(*args, **kwargs): raise ValueError("Kaboom!") - job = self.generate_mock_job() - job.result.return_value = self._get_job_result(3) - job.status.return_value = JobStatus.DONE + job = FakeJob(self.backend, self._get_job_result(3)) exp_data = ExperimentData(experiment_type="qiskit_test") exp_data.add_jobs(job) @@ -701,9 +662,7 @@ def _post_processing(*args, **kwargs): def test_status_done(self): """Test experiment status when all jobs are done.""" - job = self.generate_mock_job() - job.result.return_value = self._get_job_result(3) - job.status.return_value = JobStatus.DONE + job = FakeJob(self.backend, self._get_job_result(3)) exp_data = ExperimentData(experiment_type="qiskit_test") exp_data.add_jobs(job) exp_data.add_jobs(job) @@ -734,11 +693,12 @@ def _job_cancel(): exp_data = ExperimentData(experiment_type="qiskit_test") event = threading.Event() self.addCleanup(event.set) - job = self.generate_mock_job() - job.job_id.return_value = "1234" - job.cancel = _job_cancel - job.result = _job_result - job.status = lambda: JobStatus.CANCELLED if event.is_set() else JobStatus.RUNNING + job = FakeJob( + self.backend, + result_callback=_job_result, + cancel_callback=_job_cancel, + status_callback=lambda: JobStatus.CANCELLED if event.is_set() else JobStatus.RUNNING, + ) exp_data.add_jobs(job) with self.assertLogs("qiskit_experiments", "WARNING"): @@ -760,10 +720,11 @@ def _job_result(): def _analysis(*args): # pylint: disable = unused-argument event.wait(timeout=15) - job = self.generate_mock_job() - job.job_id.return_value = "1234" - job.result = _job_result - job.status = lambda: JobStatus.DONE if event.is_set() else JobStatus.RUNNING + job = FakeJob( + self.backend, + result_callback=_job_result, + status_callback=lambda: JobStatus.DONE if event.is_set() else JobStatus.RUNNING, + ) exp_data = ExperimentData(experiment_type="qiskit_test") exp_data.add_jobs(job) @@ -796,10 +757,11 @@ def _analysis(expdata, name=None, timeout=0): # pylint: disable = unused-argume event.wait(timeout=timeout) run_analysis.append(name) - job = self.generate_mock_job() - job.job_id.return_value = "1234" - job.result = _job_result - job.status = lambda: JobStatus.DONE if event.is_set() else JobStatus.RUNNING + job = FakeJob( + self.backend, + result_callback=_job_result, + status_callback=lambda: JobStatus.DONE if event.is_set() else JobStatus.RUNNING, + ) exp_data = ExperimentData(experiment_type="qiskit_test") exp_data.add_jobs(job) @@ -848,11 +810,12 @@ def _status(): return JobStatus.CANCELLED return JobStatus.RUNNING - job = self.generate_mock_job() - job.job_id.return_value = "1234" - job.result = _job_result - job.cancel = event.set - job.status = _status + job = FakeJob( + self.backend, + result_callback=_job_result, + cancel_callback=event.set, + status_callback=lambda: JobStatus.CANCELLED if event.is_set() else JobStatus.RUNNING, + ) exp_data = ExperimentData(experiment_type="qiskit_test") exp_data.add_jobs(job) @@ -874,11 +837,12 @@ def _job_result(): event.wait(timeout=15) raise ValueError("Job was cancelled.") - job = self.generate_mock_job() - job.job_id.return_value = "1234" - job.result = _job_result - job.cancel = event.set - job.status = lambda: JobStatus.CANCELLED if event.is_set() else JobStatus.RUNNING + job = FakeJob( + self.backend, + result_callback=_job_result, + cancel_callback=event.set, + status_callback=lambda: JobStatus.CANCELLED if event.is_set() else JobStatus.RUNNING, + ) exp_data = ExperimentData(experiment_type="qiskit_test") exp_data.add_jobs(job, timeout=0.5) @@ -906,13 +870,8 @@ def test_errors(self): def _post_processing(*args, **kwargs): # pylint: disable=unused-argument raise ValueError("Kaboom!") - job1 = self.generate_mock_job() - job1.job_id.return_value = "1234" - job1.status.return_value = JobStatus.DONE - - job2 = self.generate_mock_job() - job2.status.return_value = JobStatus.ERROR - job2.job_id.return_value = "5678" + job1 = FakeJob(self.backend, self._get_job_result(3)) + job2 = FakeJob(self.backend, status=JobStatus.ERROR) exp_data = ExperimentData(experiment_type="qiskit_test") with self.assertLogs(logger="qiskit_experiments.framework", level="WARN") as cm: @@ -922,7 +881,9 @@ def _post_processing(*args, **kwargs): # pylint: disable=unused-argument exp_data.block_for_results() self.assertEqual(ExperimentStatus.ERROR, exp_data.status()) self.assertIn("Kaboom", ",".join(cm.output)) - self.assertTrue(re.match(r".*5678.*Kaboom!", exp_data.errors(), re.DOTALL)) + self.assertTrue( + re.match(rf".*{exp_data.experiment_id}.*Kaboom!", exp_data.errors(), re.DOTALL) + ) def test_simple_methods_from_callback(self): """Test that simple methods used in call back function don't hang @@ -1034,8 +995,11 @@ def _sleeper(*args, **kwargs): # pylint: disable=unused-argument return self._get_job_result(1) sleep_count = 0 - job = self.generate_mock_job() - job.result = _sleeper + job = FakeJob( + self.backend, + result_callback=_sleeper, + status=JobStatus.DONE, + ) exp_data = ExperimentData(experiment_type="qiskit_test") exp_data.add_jobs(job) exp_data.add_analysis_callback(_sleeper) @@ -1085,13 +1049,17 @@ def _job2_result(): return job_results2 exp_data = ExperimentData(experiment_type="qiskit_test") - job = self.generate_mock_job() - job.result = _job1_result + job = FakeJob( + self.backend, + result_callback=_job1_result, + ) exp_data.add_jobs(job) copied = exp_data.copy(copy_results=False) - job2 = self.generate_mock_job() - job2.result = _job2_result + job2 = FakeJob( + self.backend, + result_callback=_job2_result, + ) copied.add_jobs(job2) event.set() @@ -1104,13 +1072,13 @@ def _job2_result(): exp_data.data(0)["counts"], [copied.data(0)["counts"], copied.data(1)["counts"]] ) - def _get_job_result(self, circ_count, label_from=0, no_metadata=False): + def _get_job_result(self, circ_count, label_from=0, no_metadata=False, job_id="some_job_id"): """Return a job result with random counts.""" job_result = { "backend_name": BackendData(self.backend).name, "backend_version": "1.1.1", "qobj_id": "1234", - "job_id": "some_job_id", + "job_id": job_id, "success": True, "results": [], } @@ -1126,14 +1094,6 @@ def _get_job_result(self, circ_count, label_from=0, no_metadata=False): return Result.from_dict(job_result) - def _set_mock_service(self): - """Add a mock service to the backend.""" - mock_provider = mock.MagicMock() - self.backend._provider = mock_provider - mock_service = mock.create_autospec(IBMExperimentService, instance=True) - mock_provider.service.return_value = mock_service - return mock_service - def test_getters(self): """Test the getters return the expected result""" data = ExperimentData() @@ -1201,7 +1161,7 @@ def test_add_delete_artifact(self): exp_data.add_artifacts(new_artifact) self.assertEqual(exp_data.artifacts("test"), new_artifact) - service = mock.create_autospec(IBMExperimentService, instance=True) + service = LocalExperimentService() exp_data.service = service exp_data.save() diff --git a/test/database_service/test_fake_service.py b/test/database_service/test_fake_service.py deleted file mode 100644 index 679a6efd00..0000000000 --- a/test/database_service/test_fake_service.py +++ /dev/null @@ -1,448 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2022. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. -""" -Test the fake service -""" - -from datetime import datetime -from test.base import QiskitExperimentsTestCase -from qiskit_experiments.test import FakeService - - -class TestFakeService(QiskitExperimentsTestCase): - """ - Test the fake service - """ - - def setUp(self): - super().setUp() - - self.service = FakeService() - - # A copy of the database, in the form of a dictionary - # To serve as a reference - self.expdict = {} - - expid = 0 - for experiment_type in range(2): - for backend_name in range(2): - for tags in range(2): - expentry = { - "experiment_id": str(expid), - "experiment_type": str(experiment_type), - "backend_name": str(backend_name), - "tags": ["a" + str(tags), "b" + str(tags)], - } - - if expid > 2: - expentry["parent_id"] = str(expid % 3) - else: - expentry["parent_id"] = None - - # Create the experiment in the service - self.service.create_experiment(**expentry) - - # We have sent the experiment for creation in the service. - # We will now update the reference dictionary self.expdict - # with columns that are set internally by the service. - - # Below we add analysis results to the experiments 0, 1, 6, and 7. - # For each of these experiments, some of the results have device - # components [0], an d some have device components [1]. - # This means that each of these experiments should eventually have - # device components [0, 1]. - if expid in [0, 1, 6, 7]: - expentry["device_components"] = [0, 1] - else: - expentry["device_components"] = [] - - # The service determines the time (see documentation in - # FakeService.create_experiment). - expentry["start_datetime"] = datetime(2022, 1, 1, expid) - - # Update the reference dictionary - self.expdict[str(expid)] = expentry - - expid += 1 - - # A reference dictionary for the analysis results - self.resdict = {} - - resid = 0 - for experiment_id in [0, 1, 6, 7]: - for result_type in range(2): - for samebool4all in range(2): - # We don't branch of each column because it makes the data too large and slows - # down the test - tags = samebool4all - quality = samebool4all - verified = samebool4all - result_data = samebool4all - device_components = samebool4all - - resentry = { - "experiment_id": str(experiment_id), - "result_type": str(result_type), - "result_id": str(resid), - "tags": ["a" + str(tags), "b" + str(tags)], - "quality": quality, - "verified": verified, - "result_data": {"value": result_data}, - "device_components": [device_components], - } - - # Create the result in the service - self.service.create_analysis_result(**resentry) - - # We have sent the experiment for creation in the service. - # We will now update the reference dictionary self.expdict - # with columns that are set internally by the service. - - # The service sets the backend to be the experiment's backend - resentry["backend_name"] = self.expdict[str(experiment_id)]["backend_name"] - - # The service determines the time (see documentation in - # FakeService.create_analysis_result). - resentry["creation_datetime"] = self.expdict[str(experiment_id)][ - "start_datetime" - ] - - # Update the reference dictionary - self.resdict[str(resid)] = resentry - - resid += 1 - - def test_creation(self): - """Test FakeService methods create_experiment and create_analysis_result""" - for df, reference_dict, id_field in zip( - [self.service.exps, self.service.results], - [self.expdict, self.resdict], - ["experiment_id", "result_id"], - ): - self.assertEqual(len(df), len(reference_dict)) - is_in_frame = [] - for i in range(len(df)): - full_entry = df.loc[i, :].to_dict() - id_value = full_entry[id_field] - self.assertTrue(id_value not in is_in_frame) - is_in_frame.append(id_value) - self.assertTrue(id_value in reference_dict) - entry = reference_dict[id_value] - self.assertTrue(entry.items() <= full_entry.items()) - - def test_query_for_single(self): - """Test FakeService methods experiment and analysis_result""" - for ( - query_method, - reference_dict, - ) in zip( - [self.service.experiment, self.service.analysis_result], [self.expdict, self.resdict] - ): - for id_value in range(len(reference_dict)): - full_entry = query_method(str(id_value)) - entry = reference_dict[str(id_value)] - self.assertTrue(entry.items() <= full_entry.items()) - - def test_experiments_query(self): - """Test FakeService.experiments""" - for experiment_type in range(2): - expids = sorted( - [ - exp["experiment_id"] - for exp in self.service.experiments( - experiment_type=str(experiment_type), limit=None - ) - ] - ) - ref_expids = sorted( - [ - exp["experiment_id"] - for exp in self.expdict.values() - if exp["experiment_type"] == str(experiment_type) - ] - ) - self.assertTrue(len(expids) > 0) - self.assertEqual(expids, ref_expids) - - for backend_name in range(2): - expids = sorted( - [ - exp["experiment_id"] - for exp in self.service.experiments(backend_name=str(backend_name), limit=None) - ] - ) - ref_expids = sorted( - [ - exp["experiment_id"] - for exp in self.expdict.values() - if exp["backend_name"] == str(backend_name) - ] - ) - self.assertTrue(len(expids) > 0) - self.assertEqual(expids, ref_expids) - - for parent_id in range(3): - expids = sorted( - [ - exp["experiment_id"] - for exp in self.service.experiments(parent_id=str(parent_id), limit=None) - ] - ) - ref_expids = sorted( - [ - exp["experiment_id"] - for exp in self.expdict.values() - if exp["parent_id"] == str(parent_id) - ] - ) - self.assertTrue(len(expids) > 0) - self.assertEqual(expids, ref_expids) - - expids = sorted( - [ - exp["experiment_id"] - for exp in self.service.experiments( - tags=["a1", "b1"], tags_operator="AND", limit=None - ) - ] - ) - ref_expids = sorted( - [ - exp["experiment_id"] - for exp in self.expdict.values() - if "a1" in exp["tags"] and "b1" in exp["tags"] - ] - ) - self.assertTrue(len(expids) > 0) - self.assertEqual(expids, ref_expids) - - expids = sorted( - [ - exp["experiment_id"] - for exp in self.service.experiments( - tags=["a1", "c1"], tags_operator="AND", limit=None - ) - ] - ) - self.assertEqual(len(expids), 0) - - expids = sorted( - [ - exp["experiment_id"] - for exp in self.service.experiments(tags=["a0", "c0"], limit=None) - ] - ) - ref_expids = sorted( - [exp["experiment_id"] for exp in self.expdict.values() if "a0" in exp["tags"]] - ) - self.assertTrue(len(expids) > 0) - self.assertEqual(expids, ref_expids) - - expids = sorted( - [ - exp["experiment_id"] - for exp in self.service.experiments( - start_datetime_before=datetime(2022, 1, 1, 6), - start_datetime_after=datetime(2022, 1, 1, 3), - limit=None, - ) - ] - ) - self.assertEqual(expids, ["3", "4", "5", "6"]) - - datetimes = [exp["start_datetime"] for exp in self.service.experiments(limit=None)] - self.assertTrue(len(datetimes) > 0) - for i in range(len(datetimes) - 1): - self.assertTrue(datetimes[i] >= datetimes[i + 1]) - - datetimes = [ - exp["start_datetime"] - for exp in self.service.experiments(sort_by="start_datetime:asc", limit=None) - ] - self.assertTrue(len(datetimes) > 0) - for i in range(len(datetimes) - 1): - self.assertTrue(datetimes[i] <= datetimes[i + 1]) - - self.assertEqual(len(self.service.experiments(limit=4)), 4) - - def test_update_experiment(self): - """Test FakeService.update_experiment""" - self.service.update_experiment(experiment_id="1", metadata="hey", notes="hi") - exp = self.service.experiment(experiment_id="1") - self.assertEqual(exp["metadata"], "hey") - self.assertEqual(exp["notes"], "hi") - - def test_delete_experiment(self): - """Test FakeService.delete_experiment""" - exps = self.service.experiments( - start_datetime_before=datetime(2022, 1, 1, 2), - start_datetime_after=datetime(2022, 1, 1, 2), - ) - self.assertEqual(len(exps), 1) - self.service.delete_experiment(experiment_id="2") - exps = self.service.experiments( - start_datetime_before=datetime(2022, 1, 1, 2), - start_datetime_after=datetime(2022, 1, 1, 2), - ) - self.assertEqual(len(exps), 0) - - def test_update_result(self): - """Test FakeService.update_analysis_result""" - self.service.update_analysis_result(result_id="1", tags=["hey"]) - res = self.service.analysis_result(result_id="1") - self.assertEqual(res["tags"], "hey") - - def test_results_query(self): - """Test FakeService.analysis_results""" - for result_type in range(2): - resids = sorted( - [ - res.result_id - for res in self.service.analysis_results( - result_type=str(result_type), limit=None - ) - ] - ) - ref_resids = sorted( - [ - res["result_id"] - for res in self.resdict.values() - if res["result_type"] == str(result_type) - ] - ) - self.assertTrue(len(resids) > 0) - self.assertEqual(resids, ref_resids) - - for experiment_id in range(2): - resids = sorted( - [ - res.result_id - for res in self.service.analysis_results( - experiment_id=str(experiment_id), limit=None - ) - ] - ) - ref_resids = sorted( - [ - res["result_id"] - for res in self.resdict.values() - if res["experiment_id"] == str(experiment_id) - ] - ) - self.assertTrue(len(resids) > 0) - self.assertEqual(resids, ref_resids) - - for quality in range(2): - resids = sorted( - [ - res.result_id - for res in self.service.analysis_results(quality=quality, limit=None) - ] - ) - ref_resids = sorted( - [res["result_id"] for res in self.resdict.values() if res["quality"] == quality] - ) - self.assertTrue(len(resids) > 0) - self.assertEqual(resids, ref_resids) - - for verified in range(2): - resids = sorted( - [ - res.result_id - for res in self.service.analysis_results(verified=verified, limit=None) - ] - ) - ref_resids = sorted( - [res["result_id"] for res in self.resdict.values() if res["verified"] == verified] - ) - self.assertTrue(len(resids) > 0) - self.assertEqual(resids, ref_resids) - - for backend_name in range(2): - resids = sorted( - [ - res.result_id - for res in self.service.analysis_results( - backend_name=str(backend_name), limit=None - ) - ] - ) - ref_resids = sorted( - [ - res["result_id"] - for res in self.resdict.values() - if res["backend_name"] == str(backend_name) - ] - ) - self.assertTrue(len(resids) > 0) - self.assertEqual(resids, ref_resids) - - resids = sorted( - [ - res.result_id - for res in self.service.analysis_results( - tags=["a1", "b1"], tags_operator="AND", limit=None - ) - ] - ) - ref_resids = sorted( - [ - res["result_id"] - for res in self.resdict.values() - if "a1" in res["tags"] and "b1" in res["tags"] - ] - ) - self.assertTrue(len(resids) > 0) - self.assertEqual(resids, ref_resids) - - resids = sorted( - [ - res.result_id - for res in self.service.analysis_results( - tags=["a1", "c1"], tags_operator="AND", limit=None - ) - ] - ) - self.assertEqual(len(resids), 0) - - resids = sorted( - [res.result_id for res in self.service.analysis_results(tags=["a0", "c0"], limit=None)] - ) - ref_resids = sorted( - [res["result_id"] for res in self.resdict.values() if "a0" in res["tags"]] - ) - self.assertTrue(len(resids) > 0) - self.assertEqual(resids, ref_resids) - - datetimes = [res.creation_datetime for res in self.service.analysis_results(limit=None)] - self.assertTrue(len(datetimes) > 0) - for i in range(len(datetimes) - 1): - self.assertTrue(datetimes[i] >= datetimes[i + 1]) - - datetimes = [ - res.creation_datetime - for res in self.service.analysis_results(sort_by="creation_datetime:asc", limit=None) - ] - self.assertTrue(len(datetimes) > 0) - for i in range(len(datetimes) - 1): - self.assertTrue(datetimes[i] <= datetimes[i + 1]) - - self.assertEqual(len(self.service.analysis_results(limit=4)), 4) - - def test_delete_result(self): - """Test FakeService.delete_analysis_result""" - results = self.service.analysis_results(experiment_id="6") - old_number = len(results) - to_delete = results[0].result_id - self.service.delete_analysis_result(result_id=to_delete) - results = self.service.analysis_results(experiment_id="6") - self.assertEqual(len(results), old_number - 1) diff --git a/test/database_service/test_local_experiment_service.py b/test/database_service/test_local_experiment_service.py new file mode 100644 index 0000000000..db61c0aff6 --- /dev/null +++ b/test/database_service/test_local_experiment_service.py @@ -0,0 +1,357 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021-2022. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Local experiment client tests""" +import unittest +import json +from dataclasses import asdict +from datetime import datetime, timezone +from tempfile import TemporaryDirectory +from typing import Any + +from test.base import QiskitExperimentsTestCase + +import yaml + +from qiskit_experiments.database_service import ( + DbAnalysisResultData, + DbExperimentData, + ExperimentEntryNotFound, + LocalExperimentService, + ResultQuality, +) + + +class TestExperimentLocalClient(QiskitExperimentsTestCase): + """Test experiment modules.""" + + def setUp(self): + """Initial class level setup.""" + super().setUp() + self.service = LocalExperimentService() + + def test_create_or_update_experiment(self): + """Tests creating an experiment""" + data = DbExperimentData( + experiment_type="test_experiment", + backend="ibmq_qasm_simulator", + metadata={"float_data": 3.14, "string_data": "foo"}, + ) + exp_id = self.service.create_or_update_experiment(data).experiment_id + self.assertIsNotNone(exp_id) + + exp = self.service.experiment(experiment_id=exp_id) + self.assertEqual(exp.experiment_type, "test_experiment") + self.assertEqual(exp.backend, "ibmq_qasm_simulator") + self.assertEqual(exp.metadata["float_data"], 3.14) + self.assertEqual(exp.metadata["string_data"], "foo") + + def test_update_experiment(self): + """Tests updating an experiment""" + data = DbExperimentData( + experiment_type="test_experiment", + backend="ibmq_qasm_simulator", + metadata={"float_data": 3.14, "string_data": "foo"}, + ) + exp_id = self.service.create_or_update_experiment(data).experiment_id + data = self.service.experiment(exp_id) + data.metadata["float_data"] = 2.71 + data.experiment_type = "foo_type" + data.notes = ["foo_note"] + self.service.create_or_update_experiment(data) + result = self.service.experiment(exp_id) + self.assertEqual(result.metadata["float_data"], 2.71) + self.assertEqual(result.experiment_type, "foo_type") + self.assertEqual(result.notes[0], "foo_note") + + def test_delete_experiment(self): + """Tests deleting an experiment""" + data = DbExperimentData( + experiment_type="test_experiment", + backend="ibmq_qasm_simulator", + ) + exp_id = self.service.create_or_update_experiment(data).experiment_id + # Check the experiment exists + self.service.experiment(experiment_id=exp_id) + self.service.delete_experiment(exp_id) + with self.assertRaises(ExperimentEntryNotFound): + self.service.experiment(experiment_id=exp_id) + + def test_create_or_update_analysis_result(self): + """Tests creating an analysis result""" + exp_id = self.service.create_or_update_experiment( + DbExperimentData(experiment_type="test_experiment", backend="ibmq_qasm_simulator") + ).experiment_id + analysis_result_value = {"str": "foo", "float": 3.14} + analysis_data = DbAnalysisResultData( + experiment_id=exp_id, + result_data=analysis_result_value, + result_type="qiskit_test", + ) + analysis_id = self.service.create_or_update_analysis_result(analysis_data) + result = self.service.analysis_result(result_id=analysis_id) + self.assertEqual(result.result_type, "qiskit_test") + self.assertEqual(result.result_data["str"], analysis_result_value["str"]) + self.assertEqual(result.result_data["float"], analysis_result_value["float"]) + + def test_get_analysis_results(self): + """Tests getting an analysis result""" + exp_id = self.service.create_or_update_experiment( + DbExperimentData(experiment_type="test_experiment", backend="ibmq_qasm_simulator") + ).experiment_id + result_ids = ["00", "01", "10", "11"] + for result_id in result_ids: + analysis_result_value = { + "str": f"foo_{result_id}", + "float": 3.14 + int(result_id), + } + analysis_data = DbAnalysisResultData( + experiment_id=exp_id, + result_id=result_id, + result_data=analysis_result_value, + result_type=f"test_get_analysis_results_{result_id[0]}", + ) + self.service.create_or_update_analysis_result(analysis_data) + results = self.service.analysis_results( + result_type="test_get_analysis_results_0", + ) + self.assertEqual(len(results), 2) + results = self.service.analysis_results(result_type="test_get_analysis_results_1") + self.assertEqual(len(results), 2) + self.assertSetEqual({r.result_data["float"] for r in results}, {3.14 + 10, 3.14 + 11}) + + def test_delete_analysis_result(self): + """Tests deleting an analysis result""" + exp_id = self.service.create_or_update_experiment( + DbExperimentData(experiment_type="test_experiment", backend="ibmq_qasm_simulator") + ).experiment_id + analysis_data = DbAnalysisResultData( + experiment_id=exp_id, + result_data={"foo": "delete_bar"}, + result_type="test_result", + ) + result_id = self.service.create_or_update_analysis_result(analysis_data) + result = self.service.analysis_result(result_id) + self.assertEqual(result.result_data["foo"], "delete_bar") + self.service.delete_analysis_result(result_id) + with self.assertRaises(ExperimentEntryNotFound): + result = self.service.analysis_result(result_id) + + def test_update_analysis_result(self): + """Test updating an analysis result.""" + result_id = self._create_analysis_result() + fit = {"value": 41.456, "variance": 4.051} + chisq = 1.3253 + + self.service.create_or_update_analysis_result( + DbAnalysisResultData( + result_id=result_id, + result_data=fit, + tags=["qiskit_test"], + quality=ResultQuality.GOOD, + verified=True, + chisq=chisq, + ), + create=False, + ) + + rresult = self.service.analysis_result(result_id) + self.assertEqual(result_id, rresult.result_id) + self.assertEqual(fit, rresult.result_data) + self.assertEqual(["qiskit_test"], rresult.tags) + self.assertEqual(ResultQuality.GOOD, rresult.quality) + self.assertTrue(rresult.verified) + self.assertEqual(chisq, rresult.chisq) + + def test_figure(self): + """Test getting a figure.""" + exp_id = self.service.create_or_update_experiment( + DbExperimentData(experiment_type="test_experiment", backend="ibmq_qasm_simulator") + ).experiment_id + hello_bytes = str.encode("hello world") + figure_name = "hello.svg" + self.service.create_or_update_figure( + experiment_id=exp_id, figure=hello_bytes, figure_name=figure_name + ) + fig = self.service.figure(exp_id, figure_name) + self.assertEqual(fig, hello_bytes) + hello_bytes = str.encode("hello world version 2") + self.service.create_or_update_figure( + experiment_id=exp_id, + figure=hello_bytes, + figure_name=figure_name, + create=False, + ) + fig = self.service.figure(exp_id, figure_name) + self.assertEqual(fig, hello_bytes) + + def test_files(self): + """Test upload and download of files""" + exp_id = self.service.create_or_update_experiment( + DbExperimentData(experiment_type="test_experiment", backend="ibmq_qasm_simulator") + ).experiment_id + hello_data = {"hello": "world", "foo": "bar"} + filename = "test_file.json" + self.service.file_upload(exp_id, filename, hello_data) + rfile_data = self.service.file_download(exp_id, filename) + self.assertEqual(hello_data, rfile_data) + self.assertTrue(self.service.experiment_has_file(exp_id, filename)) + file_list = self.service.files(exp_id)["files"] + self.assertEqual(len(file_list), 1) + self.assertEqual(file_list[0]["Key"], filename) + + exp_id2 = self.service.create_or_update_experiment( + DbExperimentData(experiment_type="test_experiment", backend="ibmq_qasm_simulator") + ).experiment_id + file_list = self.service.files(exp_id2)["files"] + self.assertEqual(len(file_list), 0) + + def test_server_setting_start_time(self): + """Tests that start time is initialized by the server unless already present""" + ref_start_dt = datetime.now(timezone.utc) + exp_id = self.service.create_or_update_experiment( + DbExperimentData( + experiment_type="qiskit_time_test", + backend="ibmq_qasm_simulator", + ) + ).experiment_id + experiments = self.service.experiments() + found = False + for exp_id in experiments: + exp = self.service.experiment(exp_id) + if exp.experiment_id == exp_id: + found = True + self.assertTrue(found) + self.assertGreaterEqual(exp.start_datetime, ref_start_dt) + + def test_file_upload_formats(self): + """Test file upload/download for JSON and YAML formats""" + exp_id = self._create_experiment() + data = {"string": "b-string", "int": 10, "float": 0.333} + yaml_data = yaml.dump(data) + json_data = json.dumps(data) + yaml_filename = "data.yaml" + json_filename = "data.json" + + self.service.file_upload(exp_id, json_filename, json_data) + rjson_data = self.service.file_download(exp_id, json_filename) + self.assertEqual(data, rjson_data) + + self.service.file_upload(exp_id, yaml_filename, yaml_data) + ryaml_data = self.service.file_download(exp_id, yaml_filename) + self.assertEqual(data, ryaml_data) + file_list = self.service.files(exp_id)["files"] + self.assertEqual(len(file_list), 2) + + def test_save_to_disk(self): + """Test round trip of data to disk""" + # Make an experiemnt, add result, add figure, add json, add artifact (zip) + # Read it all back + data = DbExperimentData( + experiment_type="test_experiment", + backend="ibmq_qasm_simulator", + metadata={"float_data": 3.14, "string_data": "foo"}, + ) + + result_value = {"str": "foo", "float": 3.14} + result_data = DbAnalysisResultData( + experiment_id=data.experiment_id, + result_data=result_value, + result_type="qiskit_test", + ) + + figure_data = b"figure_data" + figure_name = "figure.svg" + + file_data = {"string": "b-string", "int": 10, "float": 0.333} + yaml_filename = "data.yaml" + json_filename = "data.json" + + zip_data = b"zip_data" + zip_name = "data.zip" + + with TemporaryDirectory() as tmpdirname: + save_service = LocalExperimentService(db_dir=tmpdirname) + save_service.create_or_update_experiment(data) + save_service.create_or_update_analysis_result(result_data) + save_service.create_or_update_figure(data.experiment_id, figure_data, figure_name) + save_service.file_upload(data.experiment_id, json_filename, file_data) + save_service.file_upload(data.experiment_id, yaml_filename, file_data) + save_service.file_upload(data.experiment_id, zip_name, zip_data) + + load_service = LocalExperimentService(db_dir=tmpdirname) + load_data = load_service.experiment(data.experiment_id) + load_result = load_service.analysis_result(result_data.result_id) + load_figure = load_service.figure(data.experiment_id, figure_name) + load_json = load_service.file_download(data.experiment_id, json_filename) + load_yaml = load_service.file_download(data.experiment_id, yaml_filename) + load_zip = load_service.file_download(data.experiment_id, zip_name) + + # Filter values because service can insert dates and change empty values' types + data_dict = {k: v for k, v in asdict(data).items() if v} + load_data_dict = {k: v for k, v in asdict(load_data).items() if k in data_dict} + self.assertDictEqual(data_dict, load_data_dict) + + result_data_dict = {k: v for k, v in asdict(result_data).items() if v} + load_result_data_dict = { + k: v for k, v in asdict(load_result).items() if k in result_data_dict + } + self.assertEqual(result_data_dict, load_result_data_dict) + + self.assertEqual(figure_data, load_figure) + self.assertEqual(file_data, load_json) + self.assertEqual(file_data, load_yaml) + self.assertEqual(zip_data, load_zip) + + def _create_experiment( + self, + experiment_type: str | None = None, + json_encoder: json.JSONEncoder | None = None, + **kwargs, + ) -> str: + """Create a new experiment.""" + experiment_type = experiment_type or "qiskit_test" + exp_id = self.service.create_or_update_experiment( + DbExperimentData( + experiment_type=experiment_type, + **kwargs, + ), + json_encoder=json_encoder, + ).experiment_id + return exp_id + + def _create_analysis_result( + self, + exp_id: str | None = None, + result_type: str | None = None, + result_data: dict | None = None, + json_encoder: json.JSONEncoder | None = None, + **kwargs: Any, + ): + """Create a simple analysis result.""" + experiment_id = exp_id or self._create_experiment() + result_type = result_type or "qiskit_test" + result_data = result_data or {} + aresult_id = self.service.create_or_update_analysis_result( + DbAnalysisResultData( + experiment_id=experiment_id, + result_data=result_data, + result_type=result_type, + **kwargs, + ), + json_encoder=json_encoder, + ) + return aresult_id + + +if __name__ == "__main__": + unittest.main() diff --git a/test/extended_equality.py b/test/extended_equality.py index 4039e3e0fa..e3bb7d0447 100644 --- a/test/extended_equality.py +++ b/test/extended_equality.py @@ -387,9 +387,17 @@ def _check_experiment_data( data2.child_data(), **kwargs, ) + artifacts1 = data1.artifacts() + if not isinstance(artifacts1, list): + artifacts1 = [artifacts1] + artifacts2 = data2.artifacts() + if not isinstance(artifacts2, list): + artifacts2 = [artifacts2] + artifacts1.sort(key=lambda a: a.name) + artifacts2.sort(key=lambda a: a.name) artifact_equiv = is_equivalent( - data1.artifacts(), - data2.artifacts(), + artifacts1, + artifacts2, **kwargs, ) diff --git a/test/fake_experiment.py b/test/fake_experiment.py index 220a1ddd94..0d833631c0 100644 --- a/test/fake_experiment.py +++ b/test/fake_experiment.py @@ -13,7 +13,6 @@ """A FakeExperiment for testing.""" import numpy as np -import pandas as pd from matplotlib.figure import Figure as MatplotlibFigure from qiskit import QuantumCircuit from qiskit_experiments.framework import ( @@ -41,7 +40,18 @@ def _run_analysis(self, experiment_data): analysis_results = [ AnalysisResultData(f"result_{i}", value) for i, value in enumerate(rng.random(3)) ] - scatter_table = ScatterTable.from_dataframe(pd.DataFrame(columns=ScatterTable.COLUMNS)) + scatter_table = ScatterTable() + for val in range(3): + scatter_table.add_row( + xval=float(val), + yval=0.1 * val, + yerr=0.1, + series_name="model1", + series_id=0, + category="raw", + shots=1000, + analysis="FakeAnalysis", + ) fit_data = CurveFitResult( method="some_method", model_repr={"s1": "par0 * x + par1"}, diff --git a/test/framework/test_composite.py b/test/framework/test_composite.py index 5034a82f9f..c98c55bc9e 100644 --- a/test/framework/test_composite.py +++ b/test/framework/test_composite.py @@ -18,7 +18,6 @@ from test.fake_experiment import FakeExperiment, FakeAnalysis from test.base import QiskitExperimentsTestCase -from unittest import mock from ddt import ddt, data from qiskit import QuantumCircuit @@ -26,8 +25,7 @@ from qiskit_aer import AerSimulator, noise -from qiskit_ibm_experiment import IBMExperimentService - +from qiskit_experiments.database_service import LocalExperimentService from qiskit_experiments.exceptions import QiskitError from qiskit_experiments.test.utils import FakeJob from qiskit_experiments.test.fake_backend import FakeBackend @@ -284,7 +282,7 @@ def test_composite_save_load(self): Verify that saving and loading restores the original composite experiment data object """ - self.rootdata.service = IBMExperimentService(local=True, local_save=False) + self.rootdata.service = LocalExperimentService() self.rootdata.save() loaded_data = ExperimentData.load(self.rootdata.experiment_id, self.rootdata.service) self.check_if_equal(loaded_data, self.rootdata, is_a_copy=False, check_artifact=True) @@ -293,7 +291,7 @@ def test_composite_save_metadata(self): """ Verify that saving metadata and loading restores the original composite experiment data object """ - self.rootdata.service = IBMExperimentService(local=True, local_save=False) + self.rootdata.service = LocalExperimentService() self.rootdata.save_metadata() loaded_data = ExperimentData.load(self.rootdata.experiment_id, self.rootdata.service) self.check_if_equal(loaded_data, self.rootdata, is_a_copy=False) @@ -436,7 +434,7 @@ def test_composite_figures(self): par_exp = BatchExperiment([exp1, exp2], flatten_results=False) expdata = par_exp.run(FakeBackend(num_qubits=4)) self.assertExperimentDone(expdata) - expdata.service = IBMExperimentService(local=True, local_save=False) + expdata.service = LocalExperimentService() expdata.auto_save = True par_exp.analysis.run(expdata) self.assertExperimentDone(expdata) @@ -445,7 +443,7 @@ def test_composite_auto_save(self): """ Test setting autosave when using composite experiments """ - service = mock.create_autospec(IBMExperimentService, instance=True) + service = LocalExperimentService() exp1 = FakeExperiment([0, 2]) exp2 = FakeExperiment([1, 3]) par_exp = BatchExperiment([exp1, exp2], flatten_results=False) @@ -453,7 +451,9 @@ def test_composite_auto_save(self): expdata.service = service self.assertExperimentDone(expdata) expdata.auto_save = True - self.assertEqual(service.create_or_update_experiment.call_count, 3) + for child_data in expdata.child_data(): + results = service.analysis_results(experiment_id=child_data.experiment_id) + self.assertEqual(len(results), 3) def test_composite_subexp_data(self): """ diff --git a/test/framework/test_framework.py b/test/framework/test_framework.py index ce51c57930..f14dae5ec9 100644 --- a/test/framework/test_framework.py +++ b/test/framework/test_framework.py @@ -15,7 +15,9 @@ import datetime import json import pickle +import uuid from itertools import product +from tempfile import TemporaryDirectory from test.fake_experiment import FakeExperiment, FakeAnalysis from test.base import QiskitExperimentsTestCase @@ -24,10 +26,11 @@ from qiskit import QuantumCircuit from qiskit.providers.jobstatus import JobStatus +from qiskit.result import Result from qiskit.exceptions import QiskitError from qiskit_ibm_runtime.fake_provider import FakeVigoV2 -from qiskit_experiments.database_service import Qubit +from qiskit_experiments.database_service import LocalExperimentService, Qubit from qiskit_experiments.exceptions import AnalysisError from qiskit_experiments.framework import ( ExperimentData, @@ -40,7 +43,7 @@ AnalysisStatus, ) from qiskit_experiments.test.fake_backend import FakeBackend -from qiskit_experiments.test.utils import FakeJob +from qiskit_experiments.test.utils import FakeJob, FakeProvider @ddt.ddt @@ -175,6 +178,41 @@ def test_experiment_data_analysis_results_json_roundtrip(self): result2 = next(expdata2.analysis_results(dataframe=True).itertuples()) self.assertEqual(result1, result2) + def test_run_analysis_experiment_data_experiment_service_roundtrip(self): + """Test ExperimentData after experiment service roundtrip""" + provider = FakeProvider() + backend = FakeBackend() + job = FakeJob( + backend, + Result.from_dict( + { + "backend_name": backend.name, + "job_id": uuid.uuid4().hex, + "success": True, + "results": [{"shots": 100, "success": True, "data": {"counts": {"0": 100}}}], + } + ), + ) + provider.add_job(job) + expdata1 = ExperimentData() + analysis = FakeAnalysis() + expdata1.add_jobs([job]) + # Set physical qubit for more complete comparison + expdata1.metadata["physical_qubits"] = (1,) + expdata1 = analysis.run(expdata1, seed=54321) + self.assertExperimentDone(expdata1) + + with TemporaryDirectory() as tmpdir: + service = LocalExperimentService(db_dir=tmpdir) + expdata1.service = service + expdata1.save() + + expdata2 = ExperimentData.load( + expdata1.experiment_id, provider=provider, service=service + ) + + self.assertEqualExtended(expdata1, expdata2) + def test_analysis_replace_results_true(self): """Test running analysis with replace_results=True""" analysis = FakeAnalysis() diff --git a/test/library/characterization/test_readout_error.py b/test/library/characterization/test_readout_error.py index ba46d96341..c60d1cd26f 100644 --- a/test/library/characterization/test_readout_error.py +++ b/test/library/characterization/test_readout_error.py @@ -20,9 +20,9 @@ import numpy as np from qiskit.quantum_info.operators.predicates import matrix_equal from qiskit_aer import AerSimulator -from qiskit_ibm_experiment import IBMExperimentService from qiskit_ibm_runtime.fake_provider import FakeParisV2 from qiskit_experiments.library.characterization import LocalReadoutError, CorrelatedReadoutError +from qiskit_experiments.database_service import LocalExperimentService from qiskit_experiments.framework import ExperimentData from qiskit_experiments.framework import ParallelExperiment from qiskit_experiments.framework.json import ExperimentEncoder, ExperimentDecoder @@ -212,7 +212,7 @@ def test_database_save_and_load(self): exp = LocalReadoutError(qubits) exp_data = exp.run(backend) self.assertExperimentDone(exp_data) - exp_data.service = IBMExperimentService(local=True, local_save=False) + exp_data.service = LocalExperimentService() exp_data.save() loaded_data = ExperimentData.load(exp_data.experiment_id, exp_data.service) exp_res = exp_data.analysis_results(dataframe=True)