diff --git a/.vscode/settings.json b/.vscode/settings.json index f351513..60df6a8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,7 +3,6 @@ "qiskit_quantuminspire" ], "editor.codeActionsOnSave": {}, - "editor.formatOnSave": true, "black-formatter.path": [ "${command:python.interpreterPath}" ], diff --git a/qiskit_quantuminspire/qi_jobs.py b/qiskit_quantuminspire/qi_jobs.py index 0f6ef3f..f82fd50 100644 --- a/qiskit_quantuminspire/qi_jobs.py +++ b/qiskit_quantuminspire/qi_jobs.py @@ -1,7 +1,7 @@ import asyncio from typing import Any, List, Union -from compute_api_client import ApiClient, Result as JobResult, ResultsApi +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 @@ -9,9 +9,19 @@ 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 +class CircuitExecutionData: + """Class for bookkeping of individual jobs.""" + + def __init__(self, circuit: QuantumCircuit, job_id: int = None, results: List[RawJobResult] = None) -> None: + self.job_id = job_id + self.circuit = circuit + self.results = [] if results is None else results + + # Ignore type checking for QIJob due to missing Qiskit type stubs, # which causes the base class 'Job' to be treated as 'Any'. class QIJob(Job): # type: ignore[misc] @@ -27,41 +37,56 @@ def __init__( """Initialize a QIJob instance. Args: - run_input: A single/list of Qiskit QuantumCircuit objects or hybrid algorithms. + run_input: A single/list of Qiskit QuantumCircuit object(s). backend: The backend on which the job is run. While specified as `Backend` to avoid circular dependency, it is a `QIBackend`. job_id: A unique identifier for the (batch)job. **kwargs: Additional keyword arguments passed to the parent `Job` class. """ super().__init__(backend, job_id, **kwargs) - self._job_ids: List[str] = [] - self._run_input = run_input + self._cached_result: Union[Result, None] = None + self.circuits_run_data: List[CircuitExecutionData] = ( + [CircuitExecutionData(circuit=run_input)] + if isinstance(run_input, QuantumCircuit) + else [CircuitExecutionData(circuit=circuit) for circuit in run_input] + ) def submit(self) -> None: """Submit the (batch)job to the quantum inspire backend. Use compute-api-client to call the cjm endpoints in the correct order, to submit the jobs. """ - for i in range(1, 3): - self._job_ids.append(str(i)) + # Here, we will update the self.circuits_run_data and attach the job ids for each circuit + for _ in range(1, 3): + pass self.job_id = "999" # ID of the submitted batch-job - async def _fetch_job_results(self) -> List[JobResult]: + async def _fetch_job_results(self) -> None: """Fetch results for job_ids from CJM using api client.""" - results = None async with ApiClient(config()) as client: + page_reader = PageReader[PageResult, RawJobResult]() 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 + pagination_handler = page_reader.get_all + results_handler = results_api.read_results_by_job_id_results_job_job_id_get + + result_tasks = [ + pagination_handler(results_handler, job_id=circuit_data.job_id) + for circuit_data in self.circuits_run_data + ] + 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 + def result(self) -> Result: """Return the results of the job.""" - 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 + 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 def status(self) -> JobStatus: """Return the status of the (batch)job, among the values of ``JobStatus``.""" diff --git a/qiskit_quantuminspire/qi_results.py b/qiskit_quantuminspire/qi_results.py index 4579abf..dc359d4 100644 --- a/qiskit_quantuminspire/qi_results.py +++ b/qiskit_quantuminspire/qi_results.py @@ -1,7 +1,5 @@ -from typing import List - -from compute_api_client import Result as RawJobResult 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 @@ -9,48 +7,65 @@ class QIResult: """Handle QuantumInspire (batch job) results to integrate with Qiskit's Result interface.""" - def __init__(self, qinspire_results: List[RawJobResult]) -> None: - self._raw_results = qinspire_results - - def process(self, job: Job) -> Result: - """Process the raw job results obtained from QuantumInspire. + 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`. + to avoid circular dependency, it is a `QIJob`. Returns: - The processed results as a Qiskit Result. + 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._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=shots, - success=experiment_success, - data=experiment_data, - ) - results.append(experiment_result) + 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=job.backend().name, + backend_name=self._job.backend().name, backend_version="1.0.0", - qobj_id="1234", - job_id=job.job_id, + qobj_id="", + job_id=self._job.job_id, success=all(batch_job_success), results=results, ) diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 40c639e..01ba870 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -1,19 +1,23 @@ import asyncio +from typing import List, Union from unittest.mock import AsyncMock, MagicMock import pytest from pytest_mock import MockerFixture -from qiskit.providers.jobstatus import JobStatus +from qiskit import QuantumCircuit from qiskit_quantuminspire.qi_jobs import QIJob def test_result(mocker: MockerFixture) -> None: - job = QIJob(run_input="", backend=None, job_id="some-id") - mocker.patch.object(job, "status", return_value=JobStatus.DONE) + qc = QuantumCircuit(2, 2) + + job = QIJob(run_input=qc, backend=None, job_id="some-id") + + mocker.patch.object(job, "done", return_value=True) - mock_fetch_job_results = AsyncMock(return_value=[MagicMock()]) + mock_fetch_job_results = AsyncMock(return_value=MagicMock()) mocker.patch.object(job, "_fetch_job_results", mock_fetch_job_results) mock_process = mocker.patch( @@ -21,22 +25,33 @@ def test_result(mocker: MockerFixture) -> None: return_value=MagicMock(), ) - job.result() + for _ in range(4): # Check caching + job.result() - assert mock_fetch_job_results.called mock_process.assert_called_once() + mock_fetch_job_results.assert_called_once() def test_result_raises_error_when_status_not_done(mocker: MockerFixture) -> None: job = QIJob(run_input="", backend=None, job_id="some-id") - - mocker.patch.object(job, "status", return_value=JobStatus.RUNNING) - + mocker.patch.object(job, "done", return_value=False) with pytest.raises(RuntimeError): job.result() -def test_fetch_job_result(mocker: MockerFixture) -> None: +@pytest.mark.parametrize( + "circuits, expected_n_jobs", + [ + (QuantumCircuit(1, 1), 1), # Single circuit + ([QuantumCircuit(1, 1), QuantumCircuit(2, 2)], 2), # List of circuits + ], +) +def test_fetch_job_result( + mocker: MockerFixture, + page_reader_mock: MockerFixture, + circuits: Union[QuantumCircuit, List[QuantumCircuit]], + expected_n_jobs: int, +) -> None: mocker.patch( "qiskit_quantuminspire.qi_jobs.config", @@ -46,20 +61,19 @@ def test_fetch_job_result(mocker: MockerFixture) -> None: "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 + mocker.patch( + "qiskit_quantuminspire.qi_jobs.ResultsApi", + autospec=True, + ) - mock_results_api = mocker.patch("qiskit_quantuminspire.qi_jobs.ResultsApi", return_value=mock_results_api) + page_reader_mock.get_all.side_effect = [[MagicMock()] for _ in range(expected_n_jobs)] - job = QIJob(run_input="", backend=None, job_id="some-id") + job = QIJob(run_input=circuits, backend=None, job_id="some-id") + + asyncio.run(job._fetch_job_results()) - job._job_ids = [str(i) for i in range(n_jobs)] + assert len(job.circuits_run_data) == expected_n_jobs - results = asyncio.run(job._fetch_job_results()) + assert all(circuit_data.results for circuit_data in job.circuits_run_data) - 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 index 6488dfc..267bb67 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -1,96 +1,86 @@ from datetime import datetime, timezone -import pytest -from compute_api_client import BackendStatus, BackendType, Metadata, Result as JobResult +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_backend import QIBackend from qiskit_quantuminspire.qi_jobs import QIJob from qiskit_quantuminspire.qi_results import QIResult +from tests.helpers import create_backend_type -@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() -> None: + qi_backend = create_backend_type(name="qi_backend_1") + qc = QuantumCircuit(2, 2) - -def test_process(qi_job: QIJob) -> None: - 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), - ) + 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), ) - processed_results = QIResult(raw_results).process(qi_job) + ] + 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="Spin 2", + backend_name="qi_backend_1", backend_version="1.0.0", - qobj_id="1234", - job_id="100", + qobj_id="", + job_id=batch_job_id, success=True, - results=[ - ExperimentResult( - shots=100, - success=True, - meas_level=2, - data=ExperimentResultData(counts={"0x0": 256, "0x1": 256, "0x2": 256, "0x3": 256}), - ) - ], + 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 -def test_process_handles_failed_job(qi_job: QIJob) -> None: - 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")] + qi_job.circuits_run_data[0].results = [] - processed_results = QIResult(raw_results).process(qi_job) + processed_results = QIResult(qi_job).process() expected_results = Result( - backend_name="Spin 2", + backend_name="qi_backend_1", backend_version="1.0.0", - qobj_id="1234", - job_id="100", + qobj_id="", + job_id=batch_job_id, success=False, results=[ ExperimentResult( @@ -98,6 +88,7 @@ def test_process_handles_failed_job(qi_job: QIJob) -> None: success=False, meas_level=2, data=ExperimentResultData(counts={}), + header=QobjExperimentHeader(name=qi_job.circuits_run_data[0].circuit.name), ) ], date=None,