diff --git a/.vscode/settings.json b/.vscode/settings.json index 60df6a8..0181cbd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,10 +2,6 @@ "python.analysis.extraPaths": [ "qiskit_quantuminspire" ], - "editor.codeActionsOnSave": {}, - "black-formatter.path": [ - "${command:python.interpreterPath}" - ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "python.testing.pytestPath": "${command:python.interpreterPath}", diff --git a/qiskit_quantuminspire/qi_jobs.py b/qiskit_quantuminspire/qi_jobs.py index 81555e0..4e76396 100644 --- a/qiskit_quantuminspire/qi_jobs.py +++ b/qiskit_quantuminspire/qi_jobs.py @@ -1,27 +1,28 @@ import asyncio -from typing import Any, List, Union +from dataclasses import dataclass +from functools import cache +from typing import Any, Dict, List, Optional, Union from compute_api_client import ApiClient, PageResult, Result as RawJobResult, 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.qobj import QobjExperimentHeader +from qiskit.result.models import ExperimentResult, ExperimentResultData from qiskit.result.result import Result from qiskit_quantuminspire.api.client import config from qiskit_quantuminspire.api.pagination import PageReader -from qiskit_quantuminspire.qi_results import QIResult +@dataclass class CircuitExecutionData: - """Class for bookkeping of individual jobs.""" + """Class for book-keeping of individual jobs.""" - def __init__( - self, circuit: QuantumCircuit, job_id: Union[int, None] = None, results: Union[List[RawJobResult], None] = None - ) -> None: - self.job_id = job_id - self.circuit = circuit - self.results = [] if results is None else results + circuit: QuantumCircuit + job_id: Optional[int] = None + results: Optional[RawJobResult] = None # Ignore type checking for QIJob due to missing Qiskit type stubs, @@ -46,7 +47,6 @@ def __init__( **kwargs: Additional keyword arguments passed to the parent `Job` class. """ super().__init__(backend, job_id, **kwargs) - self._cached_result: Union[Result, None] = None self.circuits_run_data: List[CircuitExecutionData] = ( [CircuitExecutionData(circuit=run_input)] if isinstance(run_input, QuantumCircuit) @@ -78,18 +78,67 @@ async def _fetch_job_results(self) -> None: result_items = await asyncio.gather(*result_tasks) for circuit_data, result_item in zip(self.circuits_run_data, result_items): - circuit_data.results = result_item + circuit_data.results = None if not result_item else result_item[0] + @cache def result(self) -> Result: """Return the results of the job.""" if not self.done(): raise RuntimeError(f"(Batch)Job status is {self.status()}.") - if self._cached_result: - return self._cached_result asyncio.run(self._fetch_job_results()) - self._cached_result = QIResult(self).process() - return self._cached_result + return self._process_results() def status(self) -> JobStatus: """Return the status of the (batch)job, among the values of ``JobStatus``.""" return JobStatus.DONE + + def _process_results(self) -> Result: + """Process the raw job results obtained from QuantumInspire.""" + + results = [] + batch_job_success = [False] * len(self.circuits_run_data) + + for idx, circuit_data in enumerate(self.circuits_run_data): + qi_result = circuit_data.results + circuit_name = circuit_data.circuit.name + + if qi_result is None: + experiment_result = self._get_experiment_result(circuit_name=circuit_name) + results.append(experiment_result) + continue + experiment_result = self._get_experiment_result( + circuit_name=circuit_name, + shots=qi_result.shots_done, + counts={hex(int(key, 2)): value for key, value in qi_result.results.items()}, + experiment_success=qi_result.shots_done > 0, + ) + results.append(experiment_result) + batch_job_success[idx] = qi_result.shots_done > 0 + + result = Result( + backend_name=self.backend().name, + backend_version="1.0.0", + qobj_id="", + job_id=self.job_id, + success=all(batch_job_success), + results=results, + ) + return result + + @staticmethod + def _get_experiment_result( + circuit_name: str, + shots: int = 0, + counts: Optional[Dict[str, int]] = None, + experiment_success: bool = False, + ) -> ExperimentResult: + """Create an ExperimentResult instance based on the provided parameters.""" + experiment_data = ExperimentResultData( + counts={} if counts is None else counts, + ) + return ExperimentResult( + shots=shots, + success=experiment_success, + data=experiment_data, + header=QobjExperimentHeader(name=circuit_name), + ) diff --git a/qiskit_quantuminspire/qi_results.py b/qiskit_quantuminspire/qi_results.py deleted file mode 100644 index dc359d4..0000000 --- a/qiskit_quantuminspire/qi_results.py +++ /dev/null @@ -1,72 +0,0 @@ -from qiskit.providers import JobV1 as Job -from qiskit.qobj import QobjExperimentHeader -from qiskit.result.models import ExperimentResult, ExperimentResultData -from qiskit.result.result import Result - - -class QIResult: - """Handle QuantumInspire (batch job) results to integrate with Qiskit's Result interface.""" - - def __init__(self, job: Job) -> None: - """Initialize the result processor for QuantumInspire job results. - - Args: - job: The (batch) job for which the results were obtained. While specified as `Job` - to avoid circular dependency, it is a `QIJob`. - Returns: - None. - """ - - self._job = job # The batch job - - def process(self) -> Result: - """Process the raw job results obtained from QuantumInspire.""" - - results = [] - batch_job_success = [False] * len(self._job.circuits_run_data) - - for idx, ciruit_data in enumerate(self._job.circuits_run_data): - result_header = QobjExperimentHeader(name=ciruit_data.circuit.name) - circuit_results = ciruit_data.results - - if not circuit_results: - # For failed job, there are no results - experiment_data = ExperimentResultData( - counts={}, - ) - experiment_result = ExperimentResult( - shots=0, - success=False, - data=experiment_data, - header=result_header, - ) - results.append(experiment_result) - else: - results_valid = [False] * len(circuit_results) - for idx_res, result in enumerate(circuit_results): - shots = result.shots_done - experiment_success = result.shots_done > 0 - counts = {hex(int(key, 2)): value for key, value in result.results.items()} - results_valid[idx_res] = experiment_success - - experiment_data = ExperimentResultData( - counts=counts, - ) - experiment_result = ExperimentResult( - shots=shots, - success=experiment_success, - data=experiment_data, - header=result_header, - ) - results.append(experiment_result) - batch_job_success[idx] = all(results_valid) - - result = Result( - backend_name=self._job.backend().name, - backend_version="1.0.0", - qobj_id="", - job_id=self._job.job_id, - success=all(batch_job_success), - results=results, - ) - return result diff --git a/tests/test_jobs.py b/tests/test_jobs.py index fcef346..b899c32 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -1,12 +1,35 @@ import asyncio +from datetime import datetime, timezone from typing import List, Union from unittest.mock import AsyncMock, MagicMock import pytest +from compute_api_client import Result as RawJobResult from pytest_mock import MockerFixture from qiskit import QuantumCircuit +from qiskit.qobj import QobjExperimentHeader +from qiskit.result.models import ExperimentResult, ExperimentResultData +from qiskit.result.result import Result from qiskit_quantuminspire.qi_jobs import QIJob +from tests.helpers import create_backend_type + + +@pytest.fixture +def mock_configs_apis(mocker: MockerFixture) -> None: + mocker.patch( + "qiskit_quantuminspire.qi_jobs.config", + return_value=MagicMock(), + ) + mocker.patch( + "qiskit_quantuminspire.qi_jobs.ApiClient", + autospec=True, + ) + + mocker.patch( + "qiskit_quantuminspire.qi_jobs.ResultsApi", + autospec=True, + ) def test_result(mocker: MockerFixture) -> None: @@ -20,15 +43,13 @@ def test_result(mocker: MockerFixture) -> None: 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(), - ) + mock_process_results = MagicMock(return_value=MagicMock()) + mocker.patch.object(job, "_process_results", mock_process_results) for _ in range(4): # Check caching job.result() - mock_process.assert_called_once() + mock_process_results.assert_called_once() mock_fetch_job_results.assert_called_once() @@ -47,26 +68,12 @@ def test_result_raises_error_when_status_not_done(mocker: MockerFixture) -> None ], ) def test_fetch_job_result( - mocker: MockerFixture, page_reader_mock: AsyncMock, circuits: Union[QuantumCircuit, List[QuantumCircuit]], expected_n_jobs: int, + mock_configs_apis: None, ) -> None: - mocker.patch( - "qiskit_quantuminspire.qi_jobs.config", - return_value=MagicMock(), - ) - mocker.patch( - "qiskit_quantuminspire.qi_jobs.ApiClient", - autospec=True, - ) - - mocker.patch( - "qiskit_quantuminspire.qi_jobs.ResultsApi", - autospec=True, - ) - page_reader_mock.get_all.side_effect = [[MagicMock()] for _ in range(expected_n_jobs)] job = QIJob(run_input=circuits, backend=None, job_id="some-id") @@ -76,3 +83,104 @@ def test_fetch_job_result( assert len(job.circuits_run_data) == expected_n_jobs assert all(circuit_data.results for circuit_data in job.circuits_run_data) + + +def test_fetch_job_result_handles_invalid_results( + page_reader_mock: AsyncMock, + mock_configs_apis: None, +) -> None: + + circuits = [QuantumCircuit(1, 1), QuantumCircuit(2, 2)] + + page_reader_mock.get_all.side_effect = [[], [None]] + + job = QIJob(run_input=circuits, backend=None, job_id="some-id") + + asyncio.run(job._fetch_job_results()) + + assert all(circuit_data.results is None for circuit_data in job.circuits_run_data) + + assert len(job.circuits_run_data) == len(circuits) + + +def test_process_results() -> None: + qi_backend = create_backend_type(name="qi_backend_1") + qc = QuantumCircuit(2, 2) + + qi_job = QIJob(run_input=qc, backend=qi_backend, job_id="some-id") + batch_job_id = "100" + qi_job.job_id = batch_job_id + individual_job_id = 1 + qi_job.circuits_run_data[0].job_id = 1 # Individual job_id + qi_job.circuits_run_data[0].results = RawJobResult( + id=individual_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": 256, + "0000000001": 256, + "0000000010": 256, + "0000000011": 256, + }, + job_id=int(qi_job.job_id), + ) + processed_results = qi_job._process_results() + experiment_data = ExperimentResultData(counts={"0x0": 256, "0x1": 256, "0x2": 256, "0x3": 256}) + experiment_result = ExperimentResult( + shots=100, + success=True, + meas_level=2, + data=experiment_data, + header=QobjExperimentHeader(name=qi_job.circuits_run_data[0].circuit.name), + ) + expected_results = Result( + backend_name="qi_backend_1", + backend_version="1.0.0", + qobj_id="", + job_id=batch_job_id, + success=True, + results=[experiment_result], + date=None, + status=None, + header=None, + ) + assert processed_results.to_dict() == expected_results.to_dict() + assert processed_results.data(qc) == experiment_data.to_dict() + + +def test_process_results_handles_invalid_results() -> None: + + qi_backend = create_backend_type(name="qi_backend_1") + qc = QuantumCircuit(2, 2) + + qi_job = QIJob(run_input=qc, backend=qi_backend, job_id="some-id") + batch_job_id = "100" + qi_job.job_id = batch_job_id + qi_job.circuits_run_data[0].job_id = 1 # Individual job_id + + qi_job.circuits_run_data[0].results = None + + processed_results = qi_job._process_results() + expected_results = Result( + backend_name="qi_backend_1", + backend_version="1.0.0", + qobj_id="", + job_id=batch_job_id, + success=False, + results=[ + ExperimentResult( + shots=0, + success=False, + meas_level=2, + data=ExperimentResultData(counts={}), + header=QobjExperimentHeader(name=qi_job.circuits_run_data[0].circuit.name), + ) + ], + date=None, + status=None, + header=None, + ) + assert processed_results.to_dict() == expected_results.to_dict() diff --git a/tests/test_results.py b/tests/test_results.py deleted file mode 100644 index 267bb67..0000000 --- a/tests/test_results.py +++ /dev/null @@ -1,98 +0,0 @@ -from datetime import datetime, timezone - -from compute_api_client import Result as RawJobResult -from qiskit import QuantumCircuit -from qiskit.qobj import QobjExperimentHeader -from qiskit.result.models import ExperimentResult, ExperimentResultData -from qiskit.result.result import Result - -from qiskit_quantuminspire.qi_jobs import QIJob -from qiskit_quantuminspire.qi_results import QIResult -from tests.helpers import create_backend_type - - -def test_process() -> None: - qi_backend = create_backend_type(name="qi_backend_1") - qc = QuantumCircuit(2, 2) - - qi_job = QIJob(run_input=qc, backend=qi_backend, job_id="some-id") - batch_job_id = "100" - qi_job.job_id = batch_job_id - individual_job_id = 1 - qi_job.circuits_run_data[0].job_id = individual_job_id - raw_results = [ - RawJobResult( - id=individual_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": 256, - "0000000001": 256, - "0000000010": 256, - "0000000011": 256, - }, - job_id=int(qi_job.job_id), - ) - ] - qi_job.circuits_run_data[0].results = raw_results - processed_results = QIResult(qi_job).process() - experiment_data = ExperimentResultData(counts={"0x0": 256, "0x1": 256, "0x2": 256, "0x3": 256}) - experiment_result = ExperimentResult( - shots=100, - success=True, - meas_level=2, - data=experiment_data, - header=QobjExperimentHeader(name=qi_job.circuits_run_data[0].circuit.name), - ) - expected_results = Result( - backend_name="qi_backend_1", - backend_version="1.0.0", - qobj_id="", - job_id=batch_job_id, - success=True, - results=[experiment_result], - date=None, - status=None, - header=None, - ) - assert processed_results.to_dict() == expected_results.to_dict() - assert processed_results.data(qc) == experiment_data.to_dict() - - -def test_process_handles_failed_job() -> None: - - qi_backend = create_backend_type(name="qi_backend_1") - qc = QuantumCircuit(2, 2) - - qi_job = QIJob(run_input=qc, backend=qi_backend, job_id="some-id") - batch_job_id = "100" - qi_job.job_id = batch_job_id - individual_job_id = 1 - qi_job.circuits_run_data[0].job_id = individual_job_id - - qi_job.circuits_run_data[0].results = [] - - processed_results = QIResult(qi_job).process() - expected_results = Result( - backend_name="qi_backend_1", - backend_version="1.0.0", - qobj_id="", - job_id=batch_job_id, - success=False, - results=[ - ExperimentResult( - shots=0, - success=False, - meas_level=2, - data=ExperimentResultData(counts={}), - header=QobjExperimentHeader(name=qi_job.circuits_run_data[0].circuit.name), - ) - ], - date=None, - status=None, - header=None, - ) - assert processed_results.to_dict() == expected_results.to_dict()