From 1c6424f5f0efd075ff6907623f24873433723e7c Mon Sep 17 00:00:00 2001 From: Nischal Sehrawat Date: Thu, 12 Sep 2024 11:19:46 +0200 Subject: [PATCH] [QI2-1081] Implemented job result fetching --- .vscode/settings.json | 14 +++- qiskit_quantuminspire/qi_jobs.py | 38 ++++------ qiskit_quantuminspire/qi_results.py | 26 +++++-- tests/test_jobs.py | 65 +++++++++++++++++ tests/test_results.py | 107 ++++++++++++++++++++++++++++ 5 files changed, 218 insertions(+), 32 deletions(-) create mode 100644 tests/test_jobs.py create mode 100644 tests/test_results.py diff --git a/.vscode/settings.json b/.vscode/settings.json index c58052a..f351513 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,15 @@ { "python.analysis.extraPaths": [ "qiskit_quantuminspire" - ] -} + ], + "editor.codeActionsOnSave": {}, + "editor.formatOnSave": true, + "black-formatter.path": [ + "${command:python.interpreterPath}" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python.testing.pytestPath": "${command:python.interpreterPath}", + "python.testing.autoTestDiscoverOnSaveEnabled": true, + "python.testing.cwd": "${workspaceFolder}", +} \ No newline at end of file diff --git a/qiskit_quantuminspire/qi_jobs.py b/qiskit_quantuminspire/qi_jobs.py index 8e1bd3a..0f6ef3f 100644 --- a/qiskit_quantuminspire/qi_jobs.py +++ b/qiskit_quantuminspire/qi_jobs.py @@ -1,13 +1,14 @@ -from datetime import datetime, timezone +import asyncio from typing import Any, List, Union -from compute_api_client import Result as JobResult +from compute_api_client import ApiClient, Result as JobResult, ResultsApi from qiskit.circuit import QuantumCircuit from qiskit.providers import JobV1 as Job from qiskit.providers.backend import Backend from qiskit.providers.jobstatus import JobStatus from qiskit.result.result import Result +from qiskit_quantuminspire.api.client import config from qiskit_quantuminspire.qi_results import QIResult @@ -21,7 +22,7 @@ def __init__( run_input: Union[QuantumCircuit, List[QuantumCircuit]], backend: Union[Backend, None], job_id: str, - **kwargs: Any + **kwargs: Any, ) -> None: """Initialize a QIJob instance. @@ -45,31 +46,20 @@ def submit(self) -> None: self._job_ids.append(str(i)) self.job_id = "999" # ID of the submitted batch-job - def _fetch_job_results(self) -> List[JobResult]: + async def _fetch_job_results(self) -> List[JobResult]: """Fetch results for job_ids from CJM using api client.""" - raw_results = [] - for job_id in self._job_ids: - job_result = JobResult( - id=int(job_id), - metadata_id=1, - created_on=datetime(2022, 10, 25, 15, 37, 54, 269823, tzinfo=timezone.utc), - execution_time_in_seconds=1.23, - shots_requested=100, - shots_done=100, - results={ - "0000000000": 0.270000, - "0000000001": 0.260000, - "0000000010": 0.180000, - "0000000011": 0.290000, - }, - job_id=int(job_id), - ) - raw_results.append(job_result) - return raw_results + results = None + async with ApiClient(config()) as client: + results_api = ResultsApi(client) + result_tasks = [results_api.read_results_by_job_id_results_job_job_id_get(int(id)) for id in self._job_ids] + results = await asyncio.gather(*result_tasks, return_exceptions=True) + return results def result(self) -> Result: """Return the results of the job.""" - raw_results = self._fetch_job_results() + if self.status() is not JobStatus.DONE: + raise RuntimeError(f"Job status is {self.status}.") + raw_results = asyncio.run(self._fetch_job_results()) processed_results = QIResult(raw_results).process(self) return processed_results diff --git a/qiskit_quantuminspire/qi_results.py b/qiskit_quantuminspire/qi_results.py index 7cf2536..4579abf 100644 --- a/qiskit_quantuminspire/qi_results.py +++ b/qiskit_quantuminspire/qi_results.py @@ -23,21 +23,35 @@ def process(self, job: Job) -> Result: """ results = [] + batch_job_success = [False] * len(self._raw_results) - for result in self._raw_results: + for idx, result in enumerate(self._raw_results): + counts = {} + shots = 0 + experiment_success = False + + if isinstance(result, RawJobResult): + shots = result.shots_done + experiment_success = result.shots_done > 0 + counts = {hex(int(key, 2)): value for key, value in result.results.items()} + batch_job_success[idx] = True + + experiment_data = ExperimentResultData( + counts=counts, + ) experiment_result = ExperimentResult( - shots=result.shots_done, - success=True, - data=ExperimentResultData(), + shots=shots, + success=experiment_success, + data=experiment_data, ) results.append(experiment_result) result = Result( backend_name=job.backend().name, backend_version="1.0.0", - qobj_id=1234, + qobj_id="1234", job_id=job.job_id, - success=True, + success=all(batch_job_success), results=results, ) return result diff --git a/tests/test_jobs.py b/tests/test_jobs.py new file mode 100644 index 0000000..9a4d5ee --- /dev/null +++ b/tests/test_jobs.py @@ -0,0 +1,65 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pytest_mock import MockerFixture +from qiskit.providers.jobstatus import JobStatus + +from qiskit_quantuminspire.qi_jobs import QIJob + + +def test_result(mocker: MockerFixture): + job = QIJob(run_input="", backend=None, job_id="some-id") + + mocker.patch.object(job, "status", return_value=JobStatus.DONE) + + mock_fetch_job_results = AsyncMock(return_value=[MagicMock()]) + mocker.patch.object(job, "_fetch_job_results", mock_fetch_job_results) + + mock_process = mocker.patch( + "qiskit_quantuminspire.qi_jobs.QIResult.process", + return_value=MagicMock(), + ) + + job.result() + + assert mock_fetch_job_results.called + mock_process.assert_called_once() + + +def test_result_raises_error_when_status_not_done(mocker: MockerFixture): + job = QIJob(run_input="", backend=None, job_id="some-id") + + mocker.patch.object(job, "status", return_value=JobStatus.RUNNING) + + with pytest.raises(RuntimeError): + job.result() + + +def test_fetch_job_result(mocker: MockerFixture): + + mocker.patch( + "qiskit_quantuminspire.qi_jobs.config", + return_value=MagicMock(), + ) + mocker.patch( + "qiskit_quantuminspire.qi_jobs.ApiClient", + autospec=True, + ) + n_jobs = 3 + + mock_results_api = MagicMock() + mock_get_result_by_id = AsyncMock() + mock_get_result_by_id.side_effect = [MagicMock() for _ in range(n_jobs)] + mock_results_api.read_results_by_job_id_results_job_job_id_get = mock_get_result_by_id + + mock_results_api = mocker.patch("qiskit_quantuminspire.qi_jobs.ResultsApi", return_value=mock_results_api) + + job = QIJob(run_input="", backend=None, job_id="some-id") + + job._job_ids = [str(i) for i in range(n_jobs)] + + results = asyncio.run(job._fetch_job_results()) + + assert len(results) == n_jobs + assert mock_get_result_by_id.call_count == n_jobs diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 0000000..73adf15 --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,107 @@ +from datetime import datetime, timezone + +import pytest +from compute_api_client import BackendStatus, BackendType, Metadata, Result as JobResult +from qiskit.result.models import ExperimentResult, ExperimentResultData +from qiskit.result.result import Result + +from qiskit_quantuminspire.qi_backend import QIBackend +from qiskit_quantuminspire.qi_jobs import QIJob +from qiskit_quantuminspire.qi_results import QIResult + + +@pytest.fixture +def qi_backend() -> QIBackend: + backend_type = BackendType( + id=1, + name="Spin 2", + infrastructure="Hetzner", + description="Silicon spin quantum computer", + image_id="abcd1234", + is_hardware=True, + features=["multiple_measurements"], + default_compiler_config={}, + native_gateset={"single_qubit_gates": ["X"]}, + status=BackendStatus.IDLE, + default_number_of_shots=1024, + max_number_of_shots=2048, + ) + + metadata = Metadata(id=1, backend_id=1, created_on=datetime.now(timezone.utc), data={"nqubits": 6}) + return QIBackend(backend_type=backend_type, metadata=metadata) + + +@pytest.fixture +def qi_job(qi_backend: QIBackend) -> QIJob: + return QIJob(run_input="", backend=qi_backend, job_id="some-id") + + +def test_process(qi_job: QIJob): + qi_job._job_ids = ["1"] # The jobs in the batch job + qi_job.job_id = "100" # The batch job ID + raw_results = [] + for _id in qi_job._job_ids: + raw_results.append( + JobResult( + id=int(_id), + metadata_id=1, + created_on=datetime(2022, 10, 25, 15, 37, 54, 269823, tzinfo=timezone.utc), + execution_time_in_seconds=1.23, + shots_requested=100, + shots_done=100, + results={ + "0000000000": 256, + "0000000001": 256, + "0000000010": 256, + "0000000011": 256, + }, + job_id=int(qi_job.job_id), + ) + ) + processed_results = QIResult(raw_results).process(qi_job) + expected_results = Result( + backend_name="Spin 2", + backend_version="1.0.0", + qobj_id="1234", + job_id="100", + success=True, + results=[ + ExperimentResult( + shots=100, + success=True, + meas_level=2, + data=ExperimentResultData(counts={"0x0": 256, "0x1": 256, "0x2": 256, "0x3": 256}), + ) + ], + date=None, + status=None, + header=None, + ) + assert processed_results.to_dict() == expected_results.to_dict() + + +def test_process_handles_failed_job(qi_job: QIJob): + qi_job._job_ids = ["1"] # The jobs in the batch job + qi_job.job_id = "100" # The batch job ID + raw_results = [Exception("Bad Result")] + + processed_results = QIResult(raw_results).process(qi_job) + expected_results = Result( + backend_name="Spin 2", + backend_version="1.0.0", + qobj_id="1234", + job_id="100", + success=False, + results=[ + ExperimentResult( + shots=0, + success=False, + meas_level=2, + data=ExperimentResultData(counts={}), + ) + ], + date=None, + status=None, + header=None, + ) + assert processed_results.to_dict() == expected_results.to_dict()