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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any