qedma-api 0.14.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qedma_api/__init__.py +17 -0
- qedma_api/client.py +712 -0
- qedma_api/helpers.py +40 -0
- qedma_api/models.py +285 -0
- qedma_api/py.typed +0 -0
- qedma_api-0.14.0.dist-info/METADATA +39 -0
- qedma_api-0.14.0.dist-info/RECORD +8 -0
- qedma_api-0.14.0.dist-info/WHEEL +4 -0
qedma_api/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Qedma API package."""
|
|
2
|
+
from . import helpers
|
|
3
|
+
from .client import Client
|
|
4
|
+
from .models import (
|
|
5
|
+
Circuit,
|
|
6
|
+
CircuitOptions,
|
|
7
|
+
ExecutionMode,
|
|
8
|
+
ExpectationValue,
|
|
9
|
+
ExpectationValues,
|
|
10
|
+
IBMQProvider,
|
|
11
|
+
JobDetails,
|
|
12
|
+
JobOptions,
|
|
13
|
+
JobStatus,
|
|
14
|
+
Observable,
|
|
15
|
+
PrecisionMode,
|
|
16
|
+
TranspilationLevel,
|
|
17
|
+
)
|
qedma_api/client.py
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
"""Qedma Public API"""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from typing import overload
|
|
12
|
+
|
|
13
|
+
import loguru
|
|
14
|
+
import pydantic
|
|
15
|
+
import qiskit
|
|
16
|
+
import requests
|
|
17
|
+
from typing_extensions import Self
|
|
18
|
+
|
|
19
|
+
from qedma_api import models
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
STATUS_POLLING_INTERVAL = datetime.timedelta(seconds=10)
|
|
23
|
+
PROGRESS_POLLING_INTERVAL = datetime.timedelta(seconds=10)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JobRequest(models.RequestBase):
|
|
27
|
+
"""Request to create a new job"""
|
|
28
|
+
|
|
29
|
+
circuit: models.Circuit
|
|
30
|
+
provider: models.IBMQProvider
|
|
31
|
+
backend: str
|
|
32
|
+
empirical_time_estimation: bool
|
|
33
|
+
precision_mode: models.PrecisionMode | None = None
|
|
34
|
+
description: str = ""
|
|
35
|
+
|
|
36
|
+
@pydantic.model_validator(mode="after")
|
|
37
|
+
def validate_precision_mode(self) -> Self:
|
|
38
|
+
"""Validates the precision mode."""
|
|
39
|
+
if (self.circuit.parameters is None) != (self.precision_mode is None):
|
|
40
|
+
raise ValueError("Parameters and precision mode must be both set or unset")
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class StartJobRequest(models.RequestBase):
|
|
45
|
+
"""Start a job."""
|
|
46
|
+
|
|
47
|
+
max_qpu_time: datetime.timedelta
|
|
48
|
+
options: models.JobOptions
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GetJobsDetailsResponse(models.ResponseBase):
|
|
52
|
+
"""An internal object."""
|
|
53
|
+
|
|
54
|
+
jobs: list[models.JobDetails]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RegisterQpuTokenRequest(models.RequestBase):
|
|
58
|
+
"""Store qpu token request model"""
|
|
59
|
+
|
|
60
|
+
qpu_token: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RegisterQpuTokenResponse(models.ResponseBase):
|
|
64
|
+
"""Store qpu token request model"""
|
|
65
|
+
|
|
66
|
+
qpu_token_ref: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DecomposeResponse(models.ResponseBase):
|
|
70
|
+
"""Decompose response model"""
|
|
71
|
+
|
|
72
|
+
parametrized_circ: str
|
|
73
|
+
meas_params: dict[str, list[float]]
|
|
74
|
+
obs_per_basis: list[models.Observable]
|
|
75
|
+
relative_l2_trunc_err: float
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class QedmaServerError(Exception):
|
|
79
|
+
"""An exception raised when the server returns an error."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, message: str, details: str | None = None) -> None:
|
|
82
|
+
super().__init__(message)
|
|
83
|
+
self.message = message
|
|
84
|
+
self.details = details
|
|
85
|
+
|
|
86
|
+
def __str__(self) -> str:
|
|
87
|
+
if self.details is None:
|
|
88
|
+
return super().__str__()
|
|
89
|
+
return f"{super().__str__()}. Details: {self.details}"
|
|
90
|
+
|
|
91
|
+
def __repr__(self) -> str:
|
|
92
|
+
return f"{self.__class__.__name__}({self.message}, details={self.details})"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ResultNotReadyError(QedmaServerError):
|
|
96
|
+
"""An exception raised when the server returns an error."""
|
|
97
|
+
|
|
98
|
+
def __init__(self) -> None:
|
|
99
|
+
super().__init__("Result is not ready yet")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
ENDPOINT_URI = "https://api.qedma.io/v2/qesem"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Client: # pylint: disable=missing-class-docstring
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
api_token: str | None = None,
|
|
110
|
+
provider: models.IBMQProvider | None = None,
|
|
111
|
+
uri: str = ENDPOINT_URI,
|
|
112
|
+
timeout: int = 60,
|
|
113
|
+
) -> None:
|
|
114
|
+
self.api_token = api_token
|
|
115
|
+
self.provider = provider
|
|
116
|
+
self.uri = uri
|
|
117
|
+
self.timeout = timeout
|
|
118
|
+
self.logger = loguru.logger.bind(scope="qedma-api/client")
|
|
119
|
+
|
|
120
|
+
if sys.stderr or sys.stdout:
|
|
121
|
+
self.logger.remove()
|
|
122
|
+
|
|
123
|
+
if sys.stdout:
|
|
124
|
+
self.logger.add(
|
|
125
|
+
sys.stdout,
|
|
126
|
+
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level:<8} | {message}",
|
|
127
|
+
filter=lambda record: record["level"].no <= self.logger.level("INFO").no,
|
|
128
|
+
)
|
|
129
|
+
if sys.stderr:
|
|
130
|
+
self.logger.add(
|
|
131
|
+
sys.stderr,
|
|
132
|
+
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level:<8} | {message}",
|
|
133
|
+
filter=lambda record: record["level"].no > self.logger.level("INFO").no,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def set_provider(self, provider: models.IBMQProvider) -> None:
|
|
137
|
+
"""Set the provider of the client. (e.g. IBMQProvider)"""
|
|
138
|
+
self.provider = provider
|
|
139
|
+
|
|
140
|
+
@overload
|
|
141
|
+
def create_job( # type: ignore[no-any-unimported] # pylint: disable=too-many-arguments
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
circuit: qiskit.QuantumCircuit,
|
|
145
|
+
observables: models.Observable | Sequence[models.Observable],
|
|
146
|
+
parameters: None = None,
|
|
147
|
+
precision: float,
|
|
148
|
+
backend: str,
|
|
149
|
+
empirical_time_estimation: bool = False,
|
|
150
|
+
description: str = "",
|
|
151
|
+
circuit_options: models.CircuitOptions | None = None,
|
|
152
|
+
precision_mode: None = None,
|
|
153
|
+
) -> models.JobDetails:
|
|
154
|
+
...
|
|
155
|
+
|
|
156
|
+
@overload
|
|
157
|
+
def create_job( # type: ignore[no-any-unimported] # pylint: disable=too-many-arguments
|
|
158
|
+
self,
|
|
159
|
+
*,
|
|
160
|
+
circuit: qiskit.QuantumCircuit,
|
|
161
|
+
observables: models.Observable | Sequence[models.Observable],
|
|
162
|
+
parameters: Mapping[str | qiskit.circuit.Parameter, Sequence[float]],
|
|
163
|
+
precision: float,
|
|
164
|
+
backend: str,
|
|
165
|
+
empirical_time_estimation: bool = False,
|
|
166
|
+
description: str = "",
|
|
167
|
+
circuit_options: models.CircuitOptions | None = None,
|
|
168
|
+
precision_mode: models.PrecisionMode,
|
|
169
|
+
) -> models.JobDetails:
|
|
170
|
+
...
|
|
171
|
+
|
|
172
|
+
def create_job( # type: ignore[no-any-unimported] # pylint: disable=too-many-arguments
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
circuit: qiskit.QuantumCircuit,
|
|
176
|
+
observables: models.Observable | Sequence[models.Observable],
|
|
177
|
+
parameters: Mapping[str | qiskit.circuit.Parameter, Sequence[float]] | None = None,
|
|
178
|
+
precision: float,
|
|
179
|
+
backend: str,
|
|
180
|
+
empirical_time_estimation: bool = False,
|
|
181
|
+
description: str = "",
|
|
182
|
+
circuit_options: models.CircuitOptions | None = None,
|
|
183
|
+
precision_mode: models.PrecisionMode | None = None,
|
|
184
|
+
) -> models.JobDetails:
|
|
185
|
+
"""
|
|
186
|
+
Submit a new job to the API Gateway.
|
|
187
|
+
:param circuit: The circuit to run.
|
|
188
|
+
:param observables: The observables to measure.
|
|
189
|
+
:param precision: The target absolute precision to achieve for each input observable.
|
|
190
|
+
:param backend: The backend (QPU) to run on. (e.g., `ibm_fez`)
|
|
191
|
+
:param parameters: Used when a parameterized circuit is provided. The parameters to run the
|
|
192
|
+
circuit with (mapping from parameter to sequence of values, all parameters must have the
|
|
193
|
+
same number of values) If given, the number of observables must be equal to the number
|
|
194
|
+
of values.
|
|
195
|
+
:param empirical_time_estimation: Whether to use empirical time estimation.
|
|
196
|
+
:param description: A description for the job.
|
|
197
|
+
:param circuit_options: Additional options for a circuit.
|
|
198
|
+
:param precision_mode: The precision mode to use. Can only be used when parameters are set.
|
|
199
|
+
:return: The job's details including its ID.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
if circuit_options is None:
|
|
203
|
+
circuit_options = models.CircuitOptions()
|
|
204
|
+
|
|
205
|
+
if self.provider is None:
|
|
206
|
+
raise ValueError("Provider is not set")
|
|
207
|
+
|
|
208
|
+
if isinstance(observables, models.Observable):
|
|
209
|
+
observables = (observables,)
|
|
210
|
+
|
|
211
|
+
self.logger.info("Submitting new job")
|
|
212
|
+
response = requests.post(
|
|
213
|
+
url=f"{self.uri}/job",
|
|
214
|
+
data=JobRequest(
|
|
215
|
+
circuit=models.Circuit(
|
|
216
|
+
circuit=circuit,
|
|
217
|
+
parameters=(
|
|
218
|
+
{str(k): tuple(v) for k, v in parameters.items()}
|
|
219
|
+
if parameters is not None
|
|
220
|
+
else None
|
|
221
|
+
),
|
|
222
|
+
observables=tuple(observables),
|
|
223
|
+
precision=precision,
|
|
224
|
+
options=circuit_options,
|
|
225
|
+
),
|
|
226
|
+
provider=self.provider,
|
|
227
|
+
empirical_time_estimation=empirical_time_estimation,
|
|
228
|
+
backend=backend,
|
|
229
|
+
description=description,
|
|
230
|
+
precision_mode=precision_mode,
|
|
231
|
+
).model_dump_json(),
|
|
232
|
+
headers={"Authorization": f"Bearer {self.api_token}"},
|
|
233
|
+
timeout=self.timeout,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
self._raise_for_status(response)
|
|
237
|
+
|
|
238
|
+
resp = models.JobDetails.model_validate_json(response.content)
|
|
239
|
+
|
|
240
|
+
self.logger.info("[{job_id}] New job created", job_id=resp.job_id)
|
|
241
|
+
|
|
242
|
+
self._print_warnings_and_errors(resp)
|
|
243
|
+
|
|
244
|
+
return resp
|
|
245
|
+
|
|
246
|
+
def start_job(
|
|
247
|
+
self,
|
|
248
|
+
job_id: str,
|
|
249
|
+
max_qpu_time: datetime.timedelta,
|
|
250
|
+
options: models.JobOptions | None = None,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""
|
|
253
|
+
Start running an estimation job.
|
|
254
|
+
:param job_id: The ID of the job.
|
|
255
|
+
:param max_qpu_time: The maximum allowed QPU time.
|
|
256
|
+
:param options: Additional options for the job (see `JobOptions`).
|
|
257
|
+
"""
|
|
258
|
+
if options is None:
|
|
259
|
+
options = models.JobOptions()
|
|
260
|
+
|
|
261
|
+
job = self._get_jobs([job_id])[0]
|
|
262
|
+
if job.status != models.JobStatus.ESTIMATED:
|
|
263
|
+
self.logger.error(
|
|
264
|
+
"[{job_id}] It is not allowed to issue start_job until it is in status ESTIMATED. Please wait for the estimation to complete.", # pylint: disable=line-too-long
|
|
265
|
+
job_id=job_id,
|
|
266
|
+
)
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
self.logger.info("[{job_id}] Starting job", job_id=job_id)
|
|
270
|
+
|
|
271
|
+
response = requests.post(
|
|
272
|
+
url=f"{self.uri}/job/{job_id}/start",
|
|
273
|
+
data=StartJobRequest(max_qpu_time=max_qpu_time, options=options).model_dump_json(),
|
|
274
|
+
headers={"Authorization": f"Bearer {self.api_token}"},
|
|
275
|
+
timeout=self.timeout,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
self._raise_for_status(response)
|
|
279
|
+
|
|
280
|
+
def _create_decompose_task(
|
|
281
|
+
self,
|
|
282
|
+
mpo_file: str,
|
|
283
|
+
*,
|
|
284
|
+
max_bases: int,
|
|
285
|
+
l2_truncation_err: float,
|
|
286
|
+
op_l2_norm: float,
|
|
287
|
+
k: int,
|
|
288
|
+
pauli_coeff_th: float,
|
|
289
|
+
) -> str:
|
|
290
|
+
self.logger.info("Requesting decomposition of MPO")
|
|
291
|
+
if not os.path.exists(mpo_file):
|
|
292
|
+
raise FileNotFoundError(f"File {mpo_file} not found")
|
|
293
|
+
if not os.path.isfile(mpo_file):
|
|
294
|
+
raise FileNotFoundError(f"File {mpo_file} is not a file")
|
|
295
|
+
|
|
296
|
+
with open(mpo_file, "rb") as data_file:
|
|
297
|
+
response = requests.post(
|
|
298
|
+
url=f"{self.uri}/hpc/decompose",
|
|
299
|
+
params=[
|
|
300
|
+
("max_bases", max_bases),
|
|
301
|
+
("l2_truncation_err", l2_truncation_err),
|
|
302
|
+
("op_l2_norm", op_l2_norm),
|
|
303
|
+
("k", k),
|
|
304
|
+
("pauli_coeff_th", pauli_coeff_th),
|
|
305
|
+
],
|
|
306
|
+
files={"data_file": data_file},
|
|
307
|
+
headers={"Authorization": f"Bearer {self.api_token}"},
|
|
308
|
+
timeout=datetime.timedelta(minutes=5).total_seconds(),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if response.status_code == 404:
|
|
312
|
+
raise QedmaServerError("API endpoint not enabled")
|
|
313
|
+
|
|
314
|
+
self._raise_for_status(response)
|
|
315
|
+
|
|
316
|
+
resp_json = response.json()
|
|
317
|
+
if "task_id" not in resp_json:
|
|
318
|
+
raise QedmaServerError("Task ID not found in response", details=resp_json)
|
|
319
|
+
|
|
320
|
+
task_id = resp_json["task_id"]
|
|
321
|
+
if not isinstance(task_id, str):
|
|
322
|
+
raise QedmaServerError("Invalid task ID in response", details=resp_json)
|
|
323
|
+
|
|
324
|
+
return task_id
|
|
325
|
+
|
|
326
|
+
def _get_decompose_task_result(self, task_id: str) -> DecomposeResponse:
|
|
327
|
+
response = requests.get(
|
|
328
|
+
url=f"{self.uri}/hpc/decompose/{task_id}",
|
|
329
|
+
headers={"Authorization": f"Bearer {self.api_token}"},
|
|
330
|
+
timeout=60 * 5,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
self._raise_for_status(response)
|
|
334
|
+
if response.status_code == 202:
|
|
335
|
+
raise ResultNotReadyError()
|
|
336
|
+
|
|
337
|
+
return DecomposeResponse.model_validate_json(response.content)
|
|
338
|
+
|
|
339
|
+
def decompose( # pylint: disable=missing-function-docstring
|
|
340
|
+
self,
|
|
341
|
+
mpo_file: str,
|
|
342
|
+
*,
|
|
343
|
+
max_bases: int,
|
|
344
|
+
l2_truncation_err: float = 1e-12,
|
|
345
|
+
observable: models.Observable,
|
|
346
|
+
k: int = 1000,
|
|
347
|
+
pauli_coeff_th: float = 1e-8,
|
|
348
|
+
timeout: datetime.timedelta = datetime.timedelta(minutes=60),
|
|
349
|
+
) -> DecomposeResponse:
|
|
350
|
+
op_l2_norm = math.sqrt(sum(coeff**2 for p, coeff in observable.root.items()))
|
|
351
|
+
|
|
352
|
+
task_id = self._create_decompose_task(
|
|
353
|
+
mpo_file,
|
|
354
|
+
max_bases=max_bases,
|
|
355
|
+
l2_truncation_err=l2_truncation_err,
|
|
356
|
+
op_l2_norm=op_l2_norm,
|
|
357
|
+
k=k,
|
|
358
|
+
pauli_coeff_th=pauli_coeff_th,
|
|
359
|
+
)
|
|
360
|
+
self.logger.info("Decomposition task created. task_id: [{task_id}]", task_id=task_id)
|
|
361
|
+
|
|
362
|
+
start = datetime.datetime.now()
|
|
363
|
+
while datetime.datetime.now() - start < timeout:
|
|
364
|
+
time.sleep(0.5)
|
|
365
|
+
try:
|
|
366
|
+
return self._get_decompose_task_result(task_id)
|
|
367
|
+
except ResultNotReadyError:
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
raise TimeoutError("Decomposition task timed out")
|
|
371
|
+
|
|
372
|
+
def cancel_job(self, job_id: str) -> None:
|
|
373
|
+
"""
|
|
374
|
+
Cancel a job. Please note that the `cancel_job` API will prevent QESEM from sending
|
|
375
|
+
new circuits to the QPU. Circuits which are already running on the QPU cannot be cancelled.
|
|
376
|
+
|
|
377
|
+
:param job_id: The job_id to cancel
|
|
378
|
+
"""
|
|
379
|
+
self.logger.info("[{job_id}] Canceling job", job_id=job_id)
|
|
380
|
+
response = requests.post(
|
|
381
|
+
url=f"{self.uri}/job/{job_id}/cancel",
|
|
382
|
+
headers={"Authorization": f"Bearer {self.api_token}"},
|
|
383
|
+
timeout=self.timeout,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
self._raise_for_status(response)
|
|
387
|
+
|
|
388
|
+
def get_job(
|
|
389
|
+
self, job_id: str, include_circuits: bool = False, include_results: bool = False
|
|
390
|
+
) -> models.JobDetails:
|
|
391
|
+
"""
|
|
392
|
+
Get a job's details.
|
|
393
|
+
:param job_id: The ID of the job.
|
|
394
|
+
:param include_circuits: Whether to include the input circuit.
|
|
395
|
+
:param include_results: Whether to include the result of the job (if it is ready).
|
|
396
|
+
:return: Details about the job, with the data from the flags.
|
|
397
|
+
"""
|
|
398
|
+
job_details = self.get_jobs([job_id], include_circuits, include_results)[0]
|
|
399
|
+
|
|
400
|
+
self._print_warnings_and_errors(job_details)
|
|
401
|
+
|
|
402
|
+
return job_details
|
|
403
|
+
|
|
404
|
+
def get_jobs(
|
|
405
|
+
self, jobs_ids: list[str], include_circuits: bool = False, include_results: bool = False
|
|
406
|
+
) -> list[models.JobDetails]:
|
|
407
|
+
"""
|
|
408
|
+
Get multiple jobs' details.
|
|
409
|
+
:param jobs_ids: The IDs of the jobs.
|
|
410
|
+
:param include_circuits: Whether to include the input circuits.
|
|
411
|
+
:param include_results: Whether to include the results of the jobs (if they are ready).
|
|
412
|
+
:return: Details about the jobs, with the data from the flags.
|
|
413
|
+
"""
|
|
414
|
+
self.logger.info("Querying jobs details. jobs_ids: {jobs_ids}", jobs_ids=jobs_ids)
|
|
415
|
+
return self._get_jobs(jobs_ids, include_circuits, include_results)
|
|
416
|
+
|
|
417
|
+
def list_jobs(self, skip: int = 0, limit: int = 50) -> list[models.JobDetails]:
|
|
418
|
+
"""
|
|
419
|
+
Paginate jobs.
|
|
420
|
+
:param skip: How many jobs to skip.
|
|
421
|
+
:param limit: Maximum amount of jobs to return.
|
|
422
|
+
:return: The list of requested jobs.
|
|
423
|
+
"""
|
|
424
|
+
self.logger.info(
|
|
425
|
+
"Listing jobs details. skip: [{skip}], limit: [{limit}]", skip=skip, limit=limit
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
response = requests.get(
|
|
429
|
+
url=f"{self.uri}/jobs/list",
|
|
430
|
+
params=[("skip", skip), ("limit", limit)],
|
|
431
|
+
headers={"Authorization": f"Bearer {self.api_token}"},
|
|
432
|
+
timeout=self.timeout,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
self._raise_for_status(response)
|
|
436
|
+
|
|
437
|
+
return GetJobsDetailsResponse.model_validate_json(response.content).jobs
|
|
438
|
+
|
|
439
|
+
def register_qpu_token(self, token: str) -> str:
|
|
440
|
+
"""
|
|
441
|
+
registers the QPU vendor token.
|
|
442
|
+
:param token: The vendor token.
|
|
443
|
+
:return: QPU vendor token reference.
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
response = requests.post(
|
|
447
|
+
url=f"{self.uri}/qpu-token",
|
|
448
|
+
data=RegisterQpuTokenRequest(qpu_token=token).model_dump_json(),
|
|
449
|
+
headers={"Authorization": f"Bearer {self.api_token}"},
|
|
450
|
+
timeout=30,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
self._raise_for_status(response)
|
|
454
|
+
|
|
455
|
+
return RegisterQpuTokenResponse.model_validate_json(response.content).qpu_token_ref
|
|
456
|
+
|
|
457
|
+
def unregister_qpu_token(self, token_ref: str) -> None:
|
|
458
|
+
"""
|
|
459
|
+
Unregisters a vendor token for an account.
|
|
460
|
+
:param token_ref: The QPU vendor token reference.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
response = requests.delete(
|
|
464
|
+
url=f"{self.uri}/qpu-token/{token_ref}",
|
|
465
|
+
headers={"Authorization": f"Bearer {self.api_token}"},
|
|
466
|
+
timeout=30,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
self._raise_for_status(response)
|
|
470
|
+
|
|
471
|
+
def _get_jobs(
|
|
472
|
+
self,
|
|
473
|
+
jobs_ids: list[str],
|
|
474
|
+
include_circuits: bool = False,
|
|
475
|
+
include_results: bool = False,
|
|
476
|
+
) -> list[models.JobDetails]:
|
|
477
|
+
response = requests.get(
|
|
478
|
+
url=f"{self.uri}/jobs",
|
|
479
|
+
params=[
|
|
480
|
+
("ids", ",".join(jobs_ids)),
|
|
481
|
+
("include_circuits", include_circuits),
|
|
482
|
+
("include_results", include_results),
|
|
483
|
+
],
|
|
484
|
+
headers={"Authorization": f"Bearer {self.api_token}"},
|
|
485
|
+
timeout=self.timeout,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
self._raise_for_status(response)
|
|
489
|
+
|
|
490
|
+
return GetJobsDetailsResponse.model_validate_json(response.content).jobs
|
|
491
|
+
|
|
492
|
+
def _wait_for_status( # pylint: disable=too-many-arguments
|
|
493
|
+
self,
|
|
494
|
+
job_id: str,
|
|
495
|
+
statuses: set[models.JobStatus],
|
|
496
|
+
interval: datetime.timedelta,
|
|
497
|
+
timeout: datetime.timedelta | None,
|
|
498
|
+
*,
|
|
499
|
+
include_circuits: bool = False,
|
|
500
|
+
include_results: bool = False,
|
|
501
|
+
log_intermediate_results: bool = False,
|
|
502
|
+
) -> models.JobDetails:
|
|
503
|
+
job = self._get_jobs([job_id], include_circuits, include_results)[0]
|
|
504
|
+
start = datetime.datetime.now()
|
|
505
|
+
intermediate_results = None
|
|
506
|
+
while job.status not in statuses:
|
|
507
|
+
if timeout is not None and datetime.datetime.now() - start < timeout:
|
|
508
|
+
raise TimeoutError("The given time out passed!")
|
|
509
|
+
|
|
510
|
+
time.sleep(interval.total_seconds())
|
|
511
|
+
job = self._get_jobs([job_id], include_circuits, include_results)[0]
|
|
512
|
+
|
|
513
|
+
if log_intermediate_results and job.intermediate_results:
|
|
514
|
+
if job.intermediate_results != intermediate_results:
|
|
515
|
+
intermediate_results = job.intermediate_results
|
|
516
|
+
self.logger.info(
|
|
517
|
+
"[{job_id}] Intermediate results: [{results}]",
|
|
518
|
+
job_id=job_id,
|
|
519
|
+
results=job.intermediate_results,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
return job
|
|
523
|
+
|
|
524
|
+
def wait_for_time_estimation(
|
|
525
|
+
self,
|
|
526
|
+
job_id: str,
|
|
527
|
+
*,
|
|
528
|
+
interval: datetime.timedelta = STATUS_POLLING_INTERVAL,
|
|
529
|
+
max_poll_time: datetime.timedelta | None = None,
|
|
530
|
+
) -> datetime.timedelta | None:
|
|
531
|
+
"""
|
|
532
|
+
Wait until a job reaches the time-estimation part, and get the estimation.
|
|
533
|
+
|
|
534
|
+
:param job_id: The ID of the job.
|
|
535
|
+
:param interval: The interval between two polls. Defaults to 10 seconds.
|
|
536
|
+
:param max_poll_time: Max time until a timeout. If left empty, the method
|
|
537
|
+
will return only when the job finishes.
|
|
538
|
+
:return: The time estimation of the job.
|
|
539
|
+
:raises: `TimeoutError` if max_poll_time passed.
|
|
540
|
+
"""
|
|
541
|
+
job = self._wait_for_status(
|
|
542
|
+
job_id,
|
|
543
|
+
{
|
|
544
|
+
models.JobStatus.ESTIMATED,
|
|
545
|
+
models.JobStatus.RUNNING,
|
|
546
|
+
models.JobStatus.SUCCEEDED,
|
|
547
|
+
models.JobStatus.FAILED,
|
|
548
|
+
models.JobStatus.CANCELLED,
|
|
549
|
+
},
|
|
550
|
+
interval,
|
|
551
|
+
max_poll_time,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
self._print_warnings_and_errors(job)
|
|
555
|
+
|
|
556
|
+
time_est = job.empirical_qpu_time_estimation
|
|
557
|
+
if time_est is None:
|
|
558
|
+
time_est = job.analytical_qpu_time_estimation
|
|
559
|
+
|
|
560
|
+
if time_est is not None:
|
|
561
|
+
self.logger.info(
|
|
562
|
+
"[{job_id}] Time estimation: [{time_est} minutes]",
|
|
563
|
+
job_id=job_id,
|
|
564
|
+
time_est=time_est.total_seconds() // 60,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
return time_est
|
|
568
|
+
|
|
569
|
+
def wait_for_job_complete(
|
|
570
|
+
self,
|
|
571
|
+
job_id: str,
|
|
572
|
+
*,
|
|
573
|
+
interval: datetime.timedelta = STATUS_POLLING_INTERVAL,
|
|
574
|
+
max_poll_time: datetime.timedelta | None = None,
|
|
575
|
+
) -> models.JobDetails:
|
|
576
|
+
"""
|
|
577
|
+
Wait until the job finishes, and get the results. While the job is running,
|
|
578
|
+
this function also prints the job's current step and intermediate results
|
|
579
|
+
|
|
580
|
+
:param job_id: The ID of the job.
|
|
581
|
+
:param interval: The interval between two polls. Defaults to 10 seconds.
|
|
582
|
+
:param max_poll_time: Max time until a timeout. If left empty, the method
|
|
583
|
+
will return only when the job finishes.
|
|
584
|
+
:return: The details of the job, including its results.
|
|
585
|
+
:raises: `TimeoutError` if max_poll_time passed.
|
|
586
|
+
"""
|
|
587
|
+
stop_event = threading.Event()
|
|
588
|
+
progress_polling_thread = threading.Thread(
|
|
589
|
+
target=self._progress_listener,
|
|
590
|
+
kwargs={
|
|
591
|
+
"sampling_interval": PROGRESS_POLLING_INTERVAL.total_seconds(),
|
|
592
|
+
"print_interval": interval.total_seconds(),
|
|
593
|
+
"job_id": job_id,
|
|
594
|
+
"stop_event": stop_event,
|
|
595
|
+
},
|
|
596
|
+
daemon=True,
|
|
597
|
+
)
|
|
598
|
+
progress_polling_thread.start()
|
|
599
|
+
|
|
600
|
+
try:
|
|
601
|
+
job = self._wait_for_status(
|
|
602
|
+
job_id,
|
|
603
|
+
{
|
|
604
|
+
models.JobStatus.SUCCEEDED,
|
|
605
|
+
models.JobStatus.FAILED,
|
|
606
|
+
models.JobStatus.CANCELLED,
|
|
607
|
+
},
|
|
608
|
+
interval,
|
|
609
|
+
max_poll_time,
|
|
610
|
+
include_results=True,
|
|
611
|
+
log_intermediate_results=True,
|
|
612
|
+
)
|
|
613
|
+
finally:
|
|
614
|
+
stop_event.set()
|
|
615
|
+
progress_polling_thread.join()
|
|
616
|
+
|
|
617
|
+
self._print_warnings_and_errors(job)
|
|
618
|
+
|
|
619
|
+
results = job.results
|
|
620
|
+
self.logger.info(
|
|
621
|
+
"[{job_id}] Final results: [{results}]",
|
|
622
|
+
job_id=job_id,
|
|
623
|
+
results=results,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
return job
|
|
627
|
+
|
|
628
|
+
def _progress_listener(
|
|
629
|
+
self,
|
|
630
|
+
sampling_interval: float,
|
|
631
|
+
print_interval: float,
|
|
632
|
+
job_id: str,
|
|
633
|
+
stop_event: threading.Event,
|
|
634
|
+
) -> None:
|
|
635
|
+
last_time = time.monotonic()
|
|
636
|
+
next_step_idx = 0
|
|
637
|
+
|
|
638
|
+
while True:
|
|
639
|
+
job = self._get_jobs([job_id], include_circuits=False, include_results=False)[0]
|
|
640
|
+
|
|
641
|
+
if job.progress and job.progress.steps:
|
|
642
|
+
new_steps_count = len(job.progress.steps)
|
|
643
|
+
|
|
644
|
+
if time.monotonic() - last_time > print_interval or new_steps_count > next_step_idx:
|
|
645
|
+
last_time = time.monotonic()
|
|
646
|
+
|
|
647
|
+
if new_steps_count > next_step_idx:
|
|
648
|
+
new_steps = job.progress.steps[next_step_idx:]
|
|
649
|
+
next_step_idx = new_steps_count
|
|
650
|
+
for step in new_steps:
|
|
651
|
+
self.logger.info(f"[{job.job_id}] step: [{step.name}]")
|
|
652
|
+
|
|
653
|
+
# We break here instead in the while loop because we want to print any steps
|
|
654
|
+
# that may have been added during the last sampling interval
|
|
655
|
+
if stop_event.is_set():
|
|
656
|
+
break
|
|
657
|
+
time.sleep(sampling_interval)
|
|
658
|
+
|
|
659
|
+
def _raise_for_status(self, response: requests.Response) -> None:
|
|
660
|
+
http_error_msg = ""
|
|
661
|
+
if isinstance(response.reason, bytes):
|
|
662
|
+
# We attempt to decode utf-8 first because some servers
|
|
663
|
+
# choose to localize their reason strings. If the string
|
|
664
|
+
# isn't utf-8, we fall back to iso-8859-1 for all other
|
|
665
|
+
# encodings. (See PR #3538)
|
|
666
|
+
try:
|
|
667
|
+
reason = response.reason.decode("utf-8")
|
|
668
|
+
except UnicodeDecodeError:
|
|
669
|
+
reason = response.reason.decode("iso-8859-1")
|
|
670
|
+
else:
|
|
671
|
+
reason = response.reason
|
|
672
|
+
|
|
673
|
+
if 400 <= response.status_code < 500:
|
|
674
|
+
http_error_msg = (
|
|
675
|
+
f"{response.status_code} Client Error: {reason} for url: {response.url}"
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
elif 500 <= response.status_code < 600:
|
|
679
|
+
http_error_msg = (
|
|
680
|
+
f"{response.status_code} Server Error: {reason} for url: {response.url}"
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
if http_error_msg:
|
|
684
|
+
if not response.content:
|
|
685
|
+
raise QedmaServerError(http_error_msg)
|
|
686
|
+
|
|
687
|
+
try:
|
|
688
|
+
details = response.json().get("detail")
|
|
689
|
+
except json.JSONDecodeError:
|
|
690
|
+
raise QedmaServerError(http_error_msg) # pylint: disable=raise-missing-from
|
|
691
|
+
|
|
692
|
+
self.logger.error(
|
|
693
|
+
"Qedma server error: {http_error_msg}. Details: {details}",
|
|
694
|
+
http_error_msg=http_error_msg,
|
|
695
|
+
details=details,
|
|
696
|
+
)
|
|
697
|
+
raise QedmaServerError(http_error_msg, details=details)
|
|
698
|
+
|
|
699
|
+
def _print_warnings_and_errors(self, job_details: models.JobDetails) -> None:
|
|
700
|
+
if job_details.warnings:
|
|
701
|
+
for w in job_details.warnings:
|
|
702
|
+
self.logger.warning(w)
|
|
703
|
+
|
|
704
|
+
if job_details.errors:
|
|
705
|
+
if len(job_details.errors) == 1:
|
|
706
|
+
self.logger.error(
|
|
707
|
+
"Job creation encountered an error: {err}.", err=job_details.errors[0]
|
|
708
|
+
)
|
|
709
|
+
else:
|
|
710
|
+
self.logger.error(
|
|
711
|
+
"Job creation encountered multiple errors: {errs}.", errs=job_details.errors
|
|
712
|
+
)
|
qedma_api/helpers.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains helper functions for the qedma_api module.
|
|
3
|
+
"""
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
import qiskit.providers.backend
|
|
7
|
+
|
|
8
|
+
from qedma_api import models
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _embed_observable(
|
|
12
|
+
observable: models.Observable, qubit_map: dict[int, int]
|
|
13
|
+
) -> models.Observable:
|
|
14
|
+
new_observable = {}
|
|
15
|
+
for k, v in observable.root.items():
|
|
16
|
+
old_terms = [(op, int(s)) for op, s in re.findall(r"([XYZ])(\d+)", k)]
|
|
17
|
+
new_observable[",".join([o + str(qubit_map[q]) for o, q in old_terms])] = v
|
|
18
|
+
return models.Observable(new_observable)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def adapt_to_backend( # type: ignore[no-any-unimported]
|
|
22
|
+
circ: qiskit.QuantumCircuit,
|
|
23
|
+
observables: list[models.Observable],
|
|
24
|
+
*,
|
|
25
|
+
backend: qiskit.providers.backend.BackendV2,
|
|
26
|
+
) -> tuple[qiskit.QuantumCircuit, list[models.Observable]]:
|
|
27
|
+
"""
|
|
28
|
+
Adapt a circuit and observables to a backend qubits layout and basis gates.
|
|
29
|
+
|
|
30
|
+
Useful for running with QESEM and transpilation level 0.
|
|
31
|
+
"""
|
|
32
|
+
cm = [list(e) for e in backend.coupling_map]
|
|
33
|
+
cm = cm + [e[::-1] for e in cm]
|
|
34
|
+
transpiled_circ = qiskit.transpile(
|
|
35
|
+
circ, optimization_level=1, coupling_map=cm, basis_gates=backend.operation_names
|
|
36
|
+
)
|
|
37
|
+
qmap = dict(enumerate(transpiled_circ.layout.final_index_layout()))
|
|
38
|
+
transpiled_observables = [_embed_observable(o, qmap) for o in observables]
|
|
39
|
+
|
|
40
|
+
return transpiled_circ, transpiled_observables
|
qedma_api/models.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Qedma Public API"""
|
|
2
|
+
|
|
3
|
+
# pylint: disable=missing-function-docstring,missing-class-docstring,missing-module-docstring
|
|
4
|
+
import contextlib
|
|
5
|
+
import datetime
|
|
6
|
+
import enum
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Generator
|
|
9
|
+
from typing import Annotated, Literal
|
|
10
|
+
|
|
11
|
+
import loguru
|
|
12
|
+
import pydantic
|
|
13
|
+
import qiskit.qasm3
|
|
14
|
+
from typing_extensions import NotRequired, TypedDict
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
logger = loguru.logger
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RequestBase(pydantic.BaseModel):
|
|
21
|
+
model_config = pydantic.ConfigDict(
|
|
22
|
+
extra="forbid",
|
|
23
|
+
validate_assignment=True,
|
|
24
|
+
arbitrary_types_allowed=False,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ResponseBase(pydantic.BaseModel):
|
|
29
|
+
model_config = pydantic.ConfigDict(
|
|
30
|
+
extra="ignore",
|
|
31
|
+
validate_assignment=True,
|
|
32
|
+
arbitrary_types_allowed=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class JobStatus(str, enum.Enum):
|
|
37
|
+
ESTIMATING = "ESTIMATING"
|
|
38
|
+
ESTIMATED = "ESTIMATED"
|
|
39
|
+
RUNNING = "RUNNING"
|
|
40
|
+
SUCCEEDED = "SUCCEEDED"
|
|
41
|
+
FAILED = "FAILED"
|
|
42
|
+
CANCELLED = "CANCELLED"
|
|
43
|
+
|
|
44
|
+
def __str__(self) -> str:
|
|
45
|
+
return self.value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TranspilationLevel(enum.IntEnum):
|
|
49
|
+
LEVEL_0 = 0
|
|
50
|
+
"""
|
|
51
|
+
Minimal transpilation: the mitigated circuit will closely resemble the input
|
|
52
|
+
circuit structurally.
|
|
53
|
+
"""
|
|
54
|
+
LEVEL_1 = 1
|
|
55
|
+
""" Prepares several alternative transpilations and chooses the one that minimizes QPU time."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class IBMQProvider(RequestBase):
|
|
59
|
+
name: Literal["ibmq"] = "ibmq"
|
|
60
|
+
token_ref: str
|
|
61
|
+
instance: str # hub/group/project
|
|
62
|
+
channel: str = "ibm_quantum"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_PAULI_STRING_REGEX_STR = "^[XYZ][0-9]+(,[XYZ][0-9]+)*$"
|
|
66
|
+
Pauli = Annotated[str, pydantic.Field(pattern=_PAULI_STRING_REGEX_STR)]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Observable(pydantic.RootModel[dict[Pauli, float]]):
|
|
70
|
+
def __iter__(self) -> Generator[Pauli, None, None]: # type: ignore[override]
|
|
71
|
+
# pydantic suggests to override __iter__ method (
|
|
72
|
+
# https://docs.pydantic.dev/latest/concepts/models/#rootmodel-and-custom-root-types)
|
|
73
|
+
# but __iter__ method is already implemented in pydantic.BaseModel, so we just ignore the
|
|
74
|
+
# warning and hope that it works as expected (tests covers dump/load methods and iter)
|
|
75
|
+
yield from iter(self.root)
|
|
76
|
+
|
|
77
|
+
def __getitem__(self, key: Pauli) -> float:
|
|
78
|
+
return self.root[key]
|
|
79
|
+
|
|
80
|
+
def __contains__(self, key: Pauli) -> bool:
|
|
81
|
+
return key in self.root
|
|
82
|
+
|
|
83
|
+
def __len__(self) -> int:
|
|
84
|
+
return len(self.root)
|
|
85
|
+
|
|
86
|
+
def __str__(self) -> str:
|
|
87
|
+
return str(self.root)
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str:
|
|
90
|
+
return "Observable(" + repr(self.root) + ")"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ExpectationValue(ResponseBase):
|
|
94
|
+
value: float
|
|
95
|
+
error_bar: float
|
|
96
|
+
|
|
97
|
+
def __str__(self) -> str:
|
|
98
|
+
return f"{self.value} ± {self.error_bar}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ExpectationValues(pydantic.RootModel[list[tuple[Observable, ExpectationValue]]]):
|
|
102
|
+
def __iter__(self) -> Generator[tuple[Observable, ExpectationValue], None, None]: # type: ignore[override] # pylint: disable=line-too-long
|
|
103
|
+
# pydantic suggests to override __iter__ method (
|
|
104
|
+
# https://docs.pydantic.dev/latest/concepts/models/#rootmodel-and-custom-root-types)
|
|
105
|
+
# but __iter__ method is already implemented in pydantic.BaseModel, so we just ignore the
|
|
106
|
+
# warning and hope that it works as expected (tests covers dump/load methods and iter)
|
|
107
|
+
yield from iter(self.root)
|
|
108
|
+
|
|
109
|
+
def __getitem__(self, key: int) -> tuple[Observable, ExpectationValue]:
|
|
110
|
+
return self.root[key]
|
|
111
|
+
|
|
112
|
+
def __len__(self) -> int:
|
|
113
|
+
return len(self.root)
|
|
114
|
+
|
|
115
|
+
def __str__(self) -> str:
|
|
116
|
+
return "[" + ", ".join([f"{obs}: ({exp})" for obs, exp in self.root]) + "]"
|
|
117
|
+
|
|
118
|
+
def __repr__(self) -> str:
|
|
119
|
+
return (
|
|
120
|
+
"ExpectationValues(["
|
|
121
|
+
+ ",".join([f"{repr(obs)}: {repr(exp)}" for obs, exp in self.root])
|
|
122
|
+
+ "])"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class PrecisionMode(str, enum.Enum):
|
|
127
|
+
"""
|
|
128
|
+
Precision mode types when executing a parameterized circuit.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
JOB = "JOB"
|
|
132
|
+
""" QESEM will treat the `precision` as a precision for the sum of the expectation values."""
|
|
133
|
+
CIRCUIT = "CIRCUIT"
|
|
134
|
+
""" QESEM will target the specified `precision` for each circuit."""
|
|
135
|
+
|
|
136
|
+
def __str__(self) -> str:
|
|
137
|
+
return self.value
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ExecutionMode(str, enum.Enum):
|
|
141
|
+
"""The mode of execution."""
|
|
142
|
+
|
|
143
|
+
SESSION = "SESSION"
|
|
144
|
+
""" QESEM will execute the job in a single IBM dedicated session."""
|
|
145
|
+
BATCH = "BATCH"
|
|
146
|
+
""" QESEM will execute the job in multiple IBM batches."""
|
|
147
|
+
|
|
148
|
+
def __str__(self) -> str:
|
|
149
|
+
return self.value
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class JobOptions(RequestBase):
|
|
153
|
+
"""Additional options for a job request"""
|
|
154
|
+
|
|
155
|
+
execution_mode: ExecutionMode | None = None
|
|
156
|
+
""" Execution mode type. Default is BATCH"""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class CircuitOptions(RequestBase):
|
|
160
|
+
"""Qesem circuits circuit_options"""
|
|
161
|
+
|
|
162
|
+
transpilation_level: TranspilationLevel = pydantic.Field(default=TranspilationLevel.LEVEL_1)
|
|
163
|
+
""" Transpilation level type"""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class Circuit(RequestBase): # type: ignore[no-any-unimported]
|
|
167
|
+
circuit: qiskit.QuantumCircuit # type: ignore[no-any-unimported]
|
|
168
|
+
observables: tuple[Observable, ...]
|
|
169
|
+
parameters: dict[str, tuple[float, ...]] | None = None
|
|
170
|
+
precision: float
|
|
171
|
+
options: CircuitOptions
|
|
172
|
+
|
|
173
|
+
@pydantic.field_validator("circuit", mode="plain", json_schema_input_type=str)
|
|
174
|
+
@classmethod
|
|
175
|
+
def check_circuit(cls, value: qiskit.QuantumCircuit | str) -> qiskit.QuantumCircuit: # type: ignore[no-any-unimported] # pylint: disable=line-too-long
|
|
176
|
+
if isinstance(value, str):
|
|
177
|
+
with contextlib.suppress(Exception):
|
|
178
|
+
value = qiskit.qasm3.loads(value)
|
|
179
|
+
|
|
180
|
+
if isinstance(value, str):
|
|
181
|
+
with contextlib.suppress(Exception):
|
|
182
|
+
value = qiskit.QuantumCircuit.from_qasm_str(value)
|
|
183
|
+
|
|
184
|
+
if not isinstance(value, qiskit.QuantumCircuit):
|
|
185
|
+
raise ValueError("Circuit must be a valid Qiskit QuantumCircuit or QASM string")
|
|
186
|
+
|
|
187
|
+
return value
|
|
188
|
+
|
|
189
|
+
@pydantic.field_serializer("circuit", mode="plain", return_type=str)
|
|
190
|
+
def serialize_circuit(self, value: qiskit.QuantumCircuit) -> str: # type: ignore[no-any-unimported] # pylint: disable=line-too-long
|
|
191
|
+
result = qiskit.qasm3.dumps(value)
|
|
192
|
+
if not isinstance(result, str):
|
|
193
|
+
raise ValueError("Failed to serialize the circuit")
|
|
194
|
+
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
@pydantic.model_validator(mode="after")
|
|
198
|
+
def check_parameters(self) -> "Circuit":
|
|
199
|
+
if self.parameters is None:
|
|
200
|
+
if len(set(map(str, self.circuit.parameters))) > 0:
|
|
201
|
+
raise ValueError("Parameters must match the circuit parameters")
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
if set(map(str, self.parameters.keys())) != set(map(str, self.circuit.parameters)):
|
|
205
|
+
raise ValueError("Parameters must match the circuit parameters")
|
|
206
|
+
|
|
207
|
+
if len(self.parameters) > 0:
|
|
208
|
+
if any(
|
|
209
|
+
re.search(r"[^\w\d]", p, flags=re.U)
|
|
210
|
+
for p in self.parameters # pylint: disable=not-an-iterable
|
|
211
|
+
):
|
|
212
|
+
raise ValueError(
|
|
213
|
+
"Parameter names must contain only alphanumeric characters, got: "
|
|
214
|
+
f"{list(self.parameters.keys())}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# check all parameters are of the same length
|
|
218
|
+
parameter_value_lengths = set(len(v) for v in self.parameters.values())
|
|
219
|
+
if len(parameter_value_lengths) > 1:
|
|
220
|
+
raise ValueError("All parameter values must have the same length")
|
|
221
|
+
|
|
222
|
+
# check that the number of observables is equal to the number of parameters values
|
|
223
|
+
if len(self.observables) != list(parameter_value_lengths)[0]:
|
|
224
|
+
raise ValueError(
|
|
225
|
+
"Number of observables must be equal to the number of parameter values"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return self
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class QPUTime(TypedDict):
|
|
232
|
+
execution: datetime.timedelta
|
|
233
|
+
estimation: NotRequired[datetime.timedelta]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class ExecutionDetails(ResponseBase):
|
|
237
|
+
total_shots: int
|
|
238
|
+
mitigation_shots: int
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class JobStep(pydantic.BaseModel):
|
|
242
|
+
"""Represents a single step in a job progress"""
|
|
243
|
+
|
|
244
|
+
name: Annotated[str, pydantic.Field(description="The name of the step")]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class JobProgress(pydantic.BaseModel):
|
|
248
|
+
"""Represents job progress, i.e. a list of sequential steps"""
|
|
249
|
+
|
|
250
|
+
steps: Annotated[
|
|
251
|
+
list[JobStep],
|
|
252
|
+
pydantic.Field(
|
|
253
|
+
description="List of steps corresponding to JobStep values",
|
|
254
|
+
default_factory=list,
|
|
255
|
+
),
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class JobDetails(ResponseBase):
|
|
260
|
+
account_id: str
|
|
261
|
+
job_id: str
|
|
262
|
+
description: str = ""
|
|
263
|
+
masked_account_token: str
|
|
264
|
+
masked_qpu_token: str
|
|
265
|
+
qpu_name: str
|
|
266
|
+
circuit: Circuit | None = None
|
|
267
|
+
precision_mode: PrecisionMode | None = None
|
|
268
|
+
status: JobStatus
|
|
269
|
+
analytical_qpu_time_estimation: datetime.timedelta | None
|
|
270
|
+
empirical_qpu_time_estimation: datetime.timedelta | None = None
|
|
271
|
+
total_execution_time: datetime.timedelta
|
|
272
|
+
created_at: datetime.datetime
|
|
273
|
+
updated_at: datetime.datetime
|
|
274
|
+
qpu_time: QPUTime
|
|
275
|
+
qpu_time_limit: datetime.timedelta | None = None
|
|
276
|
+
warnings: list[str] | None = None
|
|
277
|
+
errors: list[str] | None = None
|
|
278
|
+
intermediate_results: ExpectationValues | None = None
|
|
279
|
+
results: ExpectationValues | None = None
|
|
280
|
+
noisy_results: ExpectationValues | None = None
|
|
281
|
+
execution_details: ExecutionDetails | None = None
|
|
282
|
+
progress: JobProgress | None = None
|
|
283
|
+
|
|
284
|
+
def __str__(self) -> str:
|
|
285
|
+
return self.model_dump_json(indent=4)
|
qedma_api/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: qedma-api
|
|
3
|
+
Version: 0.14.0
|
|
4
|
+
Summary: Qedma QESEM SDK
|
|
5
|
+
Author: Qedma Support
|
|
6
|
+
Author-email: support@qedma.com
|
|
7
|
+
Requires-Python: >=3.10,<3.14
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Requires-Dist: loguru (>=0.7.2,<0.8.0)
|
|
14
|
+
Requires-Dist: pydantic (>=2.9)
|
|
15
|
+
Requires-Dist: qiskit (>=1.0.2,<2.0.0)
|
|
16
|
+
Requires-Dist: qiskit-qasm3-import (>=0.5.1,<0.6.0)
|
|
17
|
+
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
18
|
+
Requires-Dist: typing-extensions (>=4.12.2,<5.0.0)
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# QEDMA API Client
|
|
22
|
+
|
|
23
|
+
Qedma’s mission is to significantly accelerate the timeline towards quantum advantage by providing QESEM (Quantum Error Suppression and Error Mitigation): a robust, cutting-edge, cross-platform Quantum error reduction software solution.
|
|
24
|
+
|
|
25
|
+
## Overview
|
|
26
|
+
|
|
27
|
+
The QEDMA API client provides a Python interface to interact with QEDMA's quantum computing services. This client allows you to create and manage jobs, monitor their status, and retrieve results.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- **Error Mitigation**: Built-in error mitigation
|
|
32
|
+
- **Real-time Monitoring**: Track job status and execution progress
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install qedma-api
|
|
38
|
+
```
|
|
39
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
qedma_api/__init__.py,sha256=bBtu445NEz3DIb1x43iH43jy1kwKnS42J2Wrogi67Ss,319
|
|
2
|
+
qedma_api/client.py,sha256=FBUR_eVbuz6J9cquxowchZLO-_gwFJ1e1B5XwUNt8EI,25052
|
|
3
|
+
qedma_api/helpers.py,sha256=aOX726PYCH2xT-VSyIvHfxu2LR1gN3Ehb2I1vW05SAc,1359
|
|
4
|
+
qedma_api/models.py,sha256=cjNjYrPkNdJWVxfIm1n_P5c4yiUWm64fgjYlA-a5QvM,9323
|
|
5
|
+
qedma_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
qedma_api-0.14.0.dist-info/METADATA,sha256=F6MMDFO2eRrr_iH_VTGdHTJ4m2QT06jvcRmbGklLDVg,1365
|
|
7
|
+
qedma_api-0.14.0.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
|
|
8
|
+
qedma_api-0.14.0.dist-info/RECORD,,
|