qoro-divi 0.3.5__tar.gz → 0.4.0__tar.gz

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.

Potentially problematic release.


This version of qoro-divi might be problematic. Click here for more details.

Files changed (72) hide show
  1. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/PKG-INFO +15 -1
  2. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/README.md +14 -0
  3. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/backends/__init__.py +1 -1
  4. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/backends/_circuit_runner.py +21 -0
  5. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/backends/_parallel_simulator.py +14 -0
  6. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/backends/_qoro_service.py +232 -70
  7. qoro_divi-0.4.0/divi/backends/_qpu_system.py +94 -0
  8. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/circuits/_core.py +24 -5
  9. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/circuits/qasm.py +1 -3
  10. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/cirq/_validator.py +12 -3
  11. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/qprog/__init__.py +1 -0
  12. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/qprog/algorithms/_ansatze.py +20 -16
  13. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/qprog/algorithms/_qaoa.py +152 -111
  14. qoro_divi-0.4.0/divi/qprog/algorithms/_vqe.py +320 -0
  15. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/qprog/batch.py +34 -1
  16. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/qprog/optimizers.py +133 -50
  17. qoro_divi-0.4.0/divi/qprog/quantum_program.py +257 -0
  18. qoro_divi-0.3.5/divi/qprog/quantum_program.py → qoro_divi-0.4.0/divi/qprog/variational_quantum_algorithm.py +236 -209
  19. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/qprog/workflows/_graph_partitioning.py +42 -6
  20. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/qprog/workflows/_qubo_partitioning.py +1 -1
  21. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/qprog/workflows/_vqe_sweep.py +40 -33
  22. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/reporting/_reporter.py +3 -6
  23. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/utils.py +65 -0
  24. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/pyproject.toml +5 -1
  25. qoro_divi-0.3.5/divi/backends/_qpu_system.py +0 -20
  26. qoro_divi-0.3.5/divi/qprog/algorithms/_vqe.py +0 -229
  27. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/LICENSE +0 -0
  28. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/LICENSES/.license-header +0 -0
  29. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/LICENSES/Apache-2.0.txt +0 -0
  30. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/__init__.py +0 -0
  31. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/circuits/__init__.py +0 -0
  32. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/circuits/qem.py +0 -0
  33. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/cirq/__init__.py +0 -0
  34. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/cirq/_lexer.py +0 -0
  35. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/cirq/_parser.py +0 -0
  36. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/cirq/_qasm_export.py +0 -0
  37. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/cirq/_qasm_import.py +0 -0
  38. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/cirq/exception.py +0 -0
  39. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/_cobyla.py +0 -0
  40. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/LICENCE.txt +0 -0
  41. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/__init__.py +0 -0
  42. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/cobyla/__init__.py +0 -0
  43. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/cobyla/cobyla.py +0 -0
  44. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/cobyla/cobylb.py +0 -0
  45. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/cobyla/geometry.py +0 -0
  46. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/cobyla/initialize.py +0 -0
  47. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/cobyla/trustregion.py +0 -0
  48. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/cobyla/update.py +0 -0
  49. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/__init__.py +0 -0
  50. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/_bounds.py +0 -0
  51. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/_linear_constraints.py +0 -0
  52. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/_nonlinear_constraints.py +0 -0
  53. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/_project.py +0 -0
  54. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/checkbreak.py +0 -0
  55. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/consts.py +0 -0
  56. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/evaluate.py +0 -0
  57. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/history.py +0 -0
  58. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/infos.py +0 -0
  59. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/linalg.py +0 -0
  60. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/message.py +0 -0
  61. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/powalg.py +0 -0
  62. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/preproc.py +0 -0
  63. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/present.py +0 -0
  64. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/ratio.py +0 -0
  65. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/redrho.py +0 -0
  66. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/extern/scipy/pyprima/common/selectx.py +0 -0
  67. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/qprog/algorithms/__init__.py +0 -0
  68. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/qprog/exceptions.py +0 -0
  69. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/qprog/workflows/__init__.py +0 -0
  70. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/reporting/__init__.py +0 -0
  71. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/reporting/_pbar.py +0 -0
  72. {qoro_divi-0.3.5 → qoro_divi-0.4.0}/divi/reporting/_qlogger.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: qoro-divi
3
- Version: 0.3.5
3
+ Version: 0.4.0
4
4
  Summary: A Python library to automate generating, parallelizing, and executing quantum programs.
5
5
  Author: Ahmed Darwish
6
6
  Author-email: ahmed@qoroquantum.de
@@ -63,3 +63,17 @@ pip install qoro-divi
63
63
  - Full documentation is available at: <https://docs.qoroquantum.net/divi>
64
64
  - Tutorials can be found in the `tutorials` folder.
65
65
 
66
+ ## 🧪 Testing
67
+
68
+ To run the test suite:
69
+
70
+ ```bash
71
+ # Run all tests
72
+ pytest
73
+
74
+ # Run only API tests (requires API token)
75
+ pytest --run-api-tests
76
+ ```
77
+
78
+ **Note:** Some tests require a Qoro API token to test the cloud REST API. Set the `QORO_API_KEY` environment variable or use the `--api-token` option. For local development, you can create a `.env`.
79
+
@@ -30,3 +30,17 @@ pip install qoro-divi
30
30
 
31
31
  - Full documentation is available at: <https://docs.qoroquantum.net/divi>
32
32
  - Tutorials can be found in the `tutorials` folder.
33
+
34
+ ## 🧪 Testing
35
+
36
+ To run the test suite:
37
+
38
+ ```bash
39
+ # Run all tests
40
+ pytest
41
+
42
+ # Run only API tests (requires API token)
43
+ pytest --run-api-tests
44
+ ```
45
+
46
+ **Note:** Some tests require a Qoro API token to test the cloud REST API. Set the `QORO_API_KEY` environment variable or use the `--api-token` option. For local development, you can create a `.env`.
@@ -4,4 +4,4 @@
4
4
 
5
5
  from ._circuit_runner import CircuitRunner
6
6
  from ._parallel_simulator import ParallelSimulator
7
- from ._qoro_service import JobStatus, JobType, QoroService
7
+ from ._qoro_service import JobConfig, JobStatus, JobType, QoroService
@@ -26,6 +26,27 @@ class CircuitRunner(ABC):
26
26
  """
27
27
  return self._shots
28
28
 
29
+ @property
30
+ @abstractmethod
31
+ def supports_expval(self) -> bool:
32
+ """
33
+ Whether the backend supports expectation value measurements.
34
+ """
35
+ return False
36
+
37
+ @property
38
+ @abstractmethod
39
+ def is_async(self) -> bool:
40
+ """
41
+ Whether the backend executes circuits asynchronously.
42
+
43
+ Returns:
44
+ bool: True if the backend returns a job ID and requires polling
45
+ for results (e.g., QoroService). False if the backend
46
+ returns results immediately (e.g., ParallelSimulator).
47
+ """
48
+ return False
49
+
29
50
  @abstractmethod
30
51
  def submit_circuits(self, circuits: dict[str, str], **kwargs):
31
52
  """
@@ -106,6 +106,20 @@ class ParallelSimulator(CircuitRunner):
106
106
  """
107
107
  self.simulation_seed = seed
108
108
 
109
+ @property
110
+ def supports_expval(self) -> bool:
111
+ """
112
+ Whether the backend supports expectation value measurements.
113
+ """
114
+ return False
115
+
116
+ @property
117
+ def is_async(self) -> bool:
118
+ """
119
+ Whether the backend executes circuits asynchronously.
120
+ """
121
+ return False
122
+
109
123
  def _execute_circuits_deterministically(
110
124
  self, circuit_labels: list[str], transpiled_circuits: list, resolved_backend
111
125
  ) -> list[dict]:
@@ -8,6 +8,7 @@ import json
8
8
  import logging
9
9
  import time
10
10
  from collections.abc import Callable
11
+ from dataclasses import dataclass, fields, replace
11
12
  from enum import Enum
12
13
  from http import HTTPStatus
13
14
 
@@ -16,7 +17,12 @@ from dotenv import dotenv_values
16
17
  from requests.adapters import HTTPAdapter, Retry
17
18
 
18
19
  from divi.backends import CircuitRunner
19
- from divi.backends._qpu_system import QPU, QPUSystem
20
+ from divi.backends._qpu_system import (
21
+ QPUSystem,
22
+ get_qpu_system,
23
+ parse_qpu_systems,
24
+ update_qpu_systems_cache,
25
+ )
20
26
  from divi.extern.cirq import is_valid_qasm
21
27
 
22
28
  API_URL = "https://app.qoroquantum.net/api"
@@ -35,18 +41,18 @@ session.mount("https://", HTTPAdapter(max_retries=retry_configuration))
35
41
 
36
42
  logger = logging.getLogger(__name__)
37
43
 
38
- _DEFAULT_QPU_SYSTEM = "qoro_maestro"
39
-
40
44
 
41
45
  def _decode_qh1_b64(encoded: dict) -> dict[str, int]:
42
46
  """
43
47
  Decode a {'encoding':'qh1','n_bits':N,'payload':base64} histogram
44
48
  into a dict with bitstring keys -> int counts.
45
49
 
46
- Returns {} if payload is empty.
50
+ If `encoded` is None, returns None.
51
+ If `encoded` is an empty dict or has a missing/empty payload, returns `encoded` unchanged.
52
+ Otherwise, decodes the payload and returns a dict mapping bitstrings to counts.
47
53
  """
48
54
  if not encoded or not encoded.get("payload"):
49
- return {}
55
+ return encoded
50
56
 
51
57
  if encoded.get("encoding") != "qh1":
52
58
  raise ValueError(f"Unsupported encoding: {encoded.get('encoding')}")
@@ -147,53 +153,150 @@ def _raise_with_details(resp: requests.Response):
147
153
 
148
154
 
149
155
  class JobStatus(Enum):
156
+ """Status of a job on the Qoro Service."""
157
+
150
158
  PENDING = "PENDING"
159
+ """Job is queued and waiting to be processed."""
160
+
151
161
  RUNNING = "RUNNING"
162
+ """Job is currently being executed."""
163
+
152
164
  COMPLETED = "COMPLETED"
165
+ """Job has finished successfully."""
166
+
153
167
  FAILED = "FAILED"
168
+ """Job execution encountered an error."""
169
+
154
170
  CANCELLED = "CANCELLED"
171
+ """Job was cancelled before completion."""
155
172
 
156
173
 
157
174
  class JobType(Enum):
175
+ """Type of job to execute on the Qoro Service."""
176
+
158
177
  EXECUTE = "EXECUTE"
178
+ """Execute circuits on real quantum hardware (sampling mode only)."""
179
+
159
180
  SIMULATE = "SIMULATE"
160
- ESTIMATE = "ESTIMATE"
181
+ """Simulate circuits using cloud-based simulation services (sampling mode)."""
182
+
183
+ EXPECTATION = "EXPECTATION"
184
+ """Compute expectation values for Hamiltonian operators (simulation only)."""
185
+
161
186
  CIRCUIT_CUT = "CIRCUIT_CUT"
187
+ """Automatically decompose large circuits that wouldn't fit on a QPU."""
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class JobConfig:
192
+ """Configuration for a Qoro Service job."""
193
+
194
+ shots: int | None = None
195
+ """Number of shots for the job."""
196
+
197
+ qpu_system: QPUSystem | str | None = None
198
+ """The QPU system to use, can be a string or a QPUSystem object."""
199
+
200
+ use_circuit_packing: bool | None = None
201
+ """Whether to use circuit packing optimization."""
202
+
203
+ tag: str = "default"
204
+ """Tag to associate with the job for identification."""
205
+
206
+ force_sampling: bool = False
207
+ """Whether to force sampling instead of expectation value measurements."""
208
+
209
+ def override(self, other: "JobConfig") -> "JobConfig":
210
+ """Creates a new config by overriding attributes with non-None values.
211
+
212
+ This method ensures immutability by always returning a new `JobConfig` object
213
+ and leaving the original instance unmodified.
214
+
215
+ Args:
216
+ other: Another JobConfig instance to take values from. Only non-None
217
+ attributes from this instance will be used for the override.
218
+
219
+ Returns:
220
+ A new JobConfig instance with the merged configurations.
221
+ """
222
+ current_attrs = {f.name: getattr(self, f.name) for f in fields(self)}
223
+
224
+ for f in fields(other):
225
+ other_value = getattr(other, f.name)
226
+ if other_value is not None:
227
+ current_attrs[f.name] = other_value
228
+
229
+ return JobConfig(**current_attrs)
230
+
231
+ def __post_init__(self):
232
+ """Sanitizes and validates the configuration."""
233
+ if self.shots is not None and self.shots <= 0:
234
+ raise ValueError(f"Shots must be a positive integer. Got {self.shots}.")
235
+
236
+ if isinstance(self.qpu_system, str):
237
+ # Defer resolution - will be resolved in QoroService.__init__() after fetch_qpu_systems()
238
+ # This allows JobConfig to be created before QoroService exists
239
+ pass
240
+ elif self.qpu_system is not None and not isinstance(self.qpu_system, QPUSystem):
241
+ raise TypeError(
242
+ f"Expected a QPUSystem instance or str, got {type(self.qpu_system)}"
243
+ )
244
+
245
+ if self.use_circuit_packing is not None and not isinstance(
246
+ self.use_circuit_packing, bool
247
+ ):
248
+ raise TypeError(f"Expected a bool, got {type(self.use_circuit_packing)}")
162
249
 
163
250
 
164
251
  class MaxRetriesReachedError(Exception):
165
252
  """Exception raised when the maximum number of retries is reached."""
166
253
 
167
- def __init__(self, retries):
254
+ def __init__(self, job_id, retries):
255
+ self.job_id = job_id
168
256
  self.retries = retries
169
- self.message = f"Maximum retries reached: {retries} retries attempted"
257
+ self.message = (
258
+ f"Maximum retries reached: {retries} retries attempted for job {job_id}"
259
+ )
170
260
  super().__init__(self.message)
171
261
 
172
262
 
173
- def _parse_qpu_systems(json_data: list) -> list[QPUSystem]:
174
- return [
175
- QPUSystem(
176
- name=system_data["name"],
177
- qpus=[QPU(**qpu) for qpu in system_data.get("qpus", [])],
178
- access_level=system_data["access_level"],
179
- )
180
- for system_data in json_data
181
- ]
263
+ _DEFAULT_QPU_SYSTEM = QPUSystem(name="qoro_maestro", supports_expval=True)
264
+
265
+ _DEFAULT_JOB_CONFIG = JobConfig(
266
+ shots=1000, qpu_system=_DEFAULT_QPU_SYSTEM, use_circuit_packing=False
267
+ )
182
268
 
183
269
 
184
270
  class QoroService(CircuitRunner):
271
+ """A client for interacting with the Qoro Quantum Service API.
272
+
273
+ This class provides methods to submit circuits, check job status,
274
+ and retrieve results from the Qoro platform.
275
+ """
185
276
 
186
277
  def __init__(
187
278
  self,
188
279
  auth_token: str | None = None,
280
+ config: JobConfig | None = None,
189
281
  polling_interval: float = 3.0,
190
282
  max_retries: int = 5000,
191
- shots: int = 1000,
192
- qpu_system_name: str | QPUSystem | None = None,
193
- use_circuit_packing: bool = False,
194
283
  ):
195
- super().__init__(shots=shots)
284
+ """Initializes the QoroService client.
285
+
286
+ Args:
287
+ auth_token (str | None, optional):
288
+ The authentication token for the Qoro API. If not provided,
289
+ it will be read from the QORO_API_KEY in a .env file.
290
+ config (JobConfig | None, optional):
291
+ A JobConfig object containing default job settings. If not
292
+ provided, a default configuration will be created.
293
+ polling_interval (float, optional):
294
+ The interval in seconds for polling job status. Defaults to 3.0.
295
+ max_retries (int, optional):
296
+ The maximum number of retries for polling. Defaults to 5000.
297
+ """
196
298
 
299
+ # Set up auth_token first (needed for API calls like fetch_qpu_systems)
197
300
  if auth_token is None:
198
301
  try:
199
302
  auth_token = dotenv_values()["QORO_API_KEY"]
@@ -203,31 +306,46 @@ class QoroService(CircuitRunner):
203
306
  self.auth_token = "Bearer " + auth_token
204
307
  self.polling_interval = polling_interval
205
308
  self.max_retries = max_retries
206
- if qpu_system_name is None:
207
- qpu_system_name = _DEFAULT_QPU_SYSTEM
208
- self._qpu_system_name = qpu_system_name
209
- self.use_circuit_packing = use_circuit_packing
210
309
 
211
- @property
212
- def qpu_system_name(self) -> str | QPUSystem | None:
213
- return self._qpu_system_name
310
+ # Fetch QPU systems (needs auth_token to be set)
311
+ self.fetch_qpu_systems()
312
+
313
+ # Set up config
314
+ if config is None:
315
+ config = _DEFAULT_JOB_CONFIG
214
316
 
215
- @qpu_system_name.setter
216
- def qpu_system_name(self, system_name: str | QPUSystem | None):
317
+ # Resolve string qpu_system names and validate that one is present.
318
+ self.config = self._resolve_and_validate_qpu_system(config)
319
+
320
+ super().__init__(shots=self.config.shots)
321
+
322
+ @property
323
+ def supports_expval(self) -> bool:
324
+ """
325
+ Whether the backend supports expectation value measurements.
217
326
  """
218
- Set the QPU system for the service.
327
+ return self.config.qpu_system.supports_expval and not self.config.force_sampling
219
328
 
220
- Args:
221
- system_name (str | QPUSystem): The QPU system to set or the name as a string.
329
+ @property
330
+ def is_async(self) -> bool:
331
+ """
332
+ Whether the backend executes circuits asynchronously.
222
333
  """
223
- if isinstance(system_name, str):
224
- self._qpu_system_name = system_name
225
- elif isinstance(system_name, QPUSystem):
226
- self._qpu_system_name = system_name.name
227
- elif system_name is None:
228
- self._qpu_system_name = None
334
+ return True
335
+
336
+ def _resolve_and_validate_qpu_system(self, config: JobConfig) -> JobConfig:
337
+ """Ensures the config has a valid QPUSystem object, resolving from string if needed."""
338
+ if config.qpu_system is None:
339
+ raise ValueError(
340
+ "JobConfig must have a qpu_system. It cannot be None. "
341
+ "Please provide a QPUSystem object or a valid system name string."
342
+ )
343
+
344
+ if isinstance(config.qpu_system, str):
345
+ resolved_qpu = get_qpu_system(config.qpu_system)
346
+ return replace(config, qpu_system=resolved_qpu)
229
347
 
230
- raise TypeError("Expected a QPUSystem instance or str.")
348
+ return config
231
349
 
232
350
  def _make_request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
233
351
  """
@@ -261,11 +379,9 @@ class QoroService(CircuitRunner):
261
379
 
262
380
  response = session.request(method, url, headers=headers, **kwargs)
263
381
 
264
- # Generic error handling for non-OK statuses
382
+ # Raise with comprehensive error details if request failed
265
383
  if response.status_code >= 400:
266
- raise requests.exceptions.HTTPError(
267
- f"API Error: {response.status_code} {response.reason} for URL {response.url}"
268
- )
384
+ _raise_with_details(response)
269
385
 
270
386
  return response
271
387
 
@@ -293,7 +409,9 @@ class QoroService(CircuitRunner):
293
409
  List of QPUSystem objects.
294
410
  """
295
411
  response = self._make_request("get", "qpusystem/", timeout=10)
296
- return _parse_qpu_systems(response.json())
412
+ systems = parse_qpu_systems(response.json())
413
+ update_qpu_systems_cache(systems)
414
+ return systems
297
415
 
298
416
  @staticmethod
299
417
  def _compress_data(value) -> bytes:
@@ -341,10 +459,9 @@ class QoroService(CircuitRunner):
341
459
  def submit_circuits(
342
460
  self,
343
461
  circuits: dict[str, str],
344
- tag: str = "default",
345
- job_type: JobType = JobType.SIMULATE,
346
- qpu_system_name: str | None = None,
347
- override_circuit_packing: bool | None = None,
462
+ ham_ops: str | None = None,
463
+ job_type: JobType | None = None,
464
+ override_config: JobConfig | None = None,
348
465
  ) -> str:
349
466
  """
350
467
  Submit quantum circuits to the Qoro API for execution.
@@ -355,15 +472,16 @@ class QoroService(CircuitRunner):
355
472
  Args:
356
473
  circuits (dict[str, str]):
357
474
  Dictionary mapping unique circuit IDs to QASM circuit strings.
358
- tag (str, optional):
359
- Tag to associate with the job for identification. Defaults to "default".
475
+ ham_ops (str | None, optional):
476
+ String representing the Hamiltonian operators to measure, semicolon-separated.
477
+ Each term is a combination of Pauli operators, e.g. "XYZ;XXZ;ZIZ".
478
+ If None, no Hamiltonian operators will be measured.
360
479
  job_type (JobType, optional):
361
- Type of job to execute (e.g., SIMULATE, EXECUTE, ESTIMATE, CIRCUIT_CUT).
362
- Defaults to JobType.SIMULATE.
363
- qpu_system_name (str | None, optional):
364
- The name of the QPU system to use. Overrides the service's default.
365
- override_circuit_packing (bool | None, optional):
366
- Whether to use circuit packing optimization. Overrides the service's default.
480
+ Type of job to execute (e.g., SIMULATE, EXECUTE, EXPECTATION, CIRCUIT_CUT).
481
+ If not provided, the job type will be determined from the service configuration.
482
+ override_config (JobConfig | None, optional):
483
+ Configuration object to override the service's default settings.
484
+ If not provided, default values are used.
367
485
 
368
486
  Raises:
369
487
  ValueError: If more than one circuit is submitted for a CIRCUIT_CUT job,
@@ -373,24 +491,62 @@ class QoroService(CircuitRunner):
373
491
  Returns:
374
492
  str: The job ID for the created job.
375
493
  """
494
+ # Create final job configuration by layering configurations:
495
+ # service defaults -> user overrides
496
+ if override_config:
497
+ config = self.config.override(override_config)
498
+ job_config = self._resolve_and_validate_qpu_system(config)
499
+ else:
500
+ job_config = self.config
501
+
502
+ # Handle Hamiltonian operators: validate compatibility and auto-infer job type
503
+ if ham_ops is not None:
504
+ # Validate that if job_type is explicitly set, it must be EXPECTATION
505
+ if job_type is not None and job_type != JobType.EXPECTATION:
506
+ raise ValueError(
507
+ "Hamiltonian operators are only supported for EXPECTATION job type."
508
+ )
509
+ # Auto-infer job type if not explicitly set
510
+ if job_type is None:
511
+ job_type = JobType.EXPECTATION
512
+
513
+ # Validate observables format
514
+
515
+ terms = ham_ops.split(";")
516
+ if len(terms) == 0:
517
+ raise ValueError(
518
+ "Hamiltonian operators must be non-empty semicolon-separated strings."
519
+ )
520
+ ham_ops_length = len(terms[0])
521
+ if not all(len(term) == ham_ops_length for term in terms):
522
+ raise ValueError("All Hamiltonian operators must have the same length.")
523
+ # Validate that each term only contains I, X, Y, Z
524
+ valid_paulis = {"I", "X", "Y", "Z"}
525
+ if not all(all(c in valid_paulis for c in term) for term in terms):
526
+ raise ValueError(
527
+ "Hamiltonian operators must contain only I, X, Y, Z characters."
528
+ )
529
+
530
+ if job_type is None:
531
+ job_type = JobType.SIMULATE
532
+
533
+ # Validate circuits
376
534
  if job_type == JobType.CIRCUIT_CUT and len(circuits) > 1:
377
535
  raise ValueError("Only one circuit allowed for circuit-cutting jobs.")
378
536
 
379
537
  for key, circuit in circuits.items():
380
- if not (err := is_valid_qasm(circuit)):
381
- raise ValueError(f"Circuit '{key}' is not a valid QASM: {err}")
538
+ result = is_valid_qasm(circuit)
539
+ if isinstance(result, str):
540
+ raise ValueError(f"Circuit '{key}' is not a valid QASM: {result}")
382
541
 
383
- # 1. Initialize the job without circuits to get a job_id
542
+ # Initialize the job without circuits to get a job_id
384
543
  init_payload = {
385
- "shots": self.shots,
386
- "tag": tag,
544
+ "tag": job_config.tag,
387
545
  "job_type": job_type.value,
388
- "qpu_system_name": qpu_system_name or self.qpu_system_name,
389
- "use_packing": (
390
- override_circuit_packing
391
- if override_circuit_packing is not None
392
- else self.use_circuit_packing
546
+ "qpu_system_name": (
547
+ job_config.qpu_system.name if job_config.qpu_system else None
393
548
  ),
549
+ "use_packing": job_config.use_circuit_packing or False,
394
550
  }
395
551
 
396
552
  init_response = self._make_request(
@@ -400,7 +556,7 @@ class QoroService(CircuitRunner):
400
556
  _raise_with_details(init_response)
401
557
  job_id = init_response.json()["job_id"]
402
558
 
403
- # 2. Split circuits and add them to the created job
559
+ # Split circuits and add them to the created job
404
560
  circuit_chunks = self._split_circuits(circuits)
405
561
  num_chunks = len(circuit_chunks)
406
562
 
@@ -408,11 +564,16 @@ class QoroService(CircuitRunner):
408
564
  is_last_chunk = i == num_chunks - 1
409
565
  add_circuits_payload = {
410
566
  "circuits": chunk,
411
- "shots": self.shots,
412
567
  "mode": "append",
413
568
  "finalized": "true" if is_last_chunk else "false",
414
569
  }
415
570
 
571
+ # Include shots/ham_ops in add_circuits payload
572
+ if ham_ops is not None:
573
+ add_circuits_payload["observables"] = ham_ops
574
+ else:
575
+ add_circuits_payload["shots"] = job_config.shots
576
+
416
577
  add_circuits_response = self._make_request(
417
578
  "post",
418
579
  f"job/{job_id}/add_circuits/",
@@ -465,6 +626,7 @@ class QoroService(CircuitRunner):
465
626
 
466
627
  # If the request was successful, process the data
467
628
  data = response.json()
629
+
468
630
  for result in data["results"]:
469
631
  result["results"] = _decode_qh1_b64(result["results"])
470
632
  return data["results"]
@@ -528,4 +690,4 @@ class QoroService(CircuitRunner):
528
690
  update_fn(retry_count, status)
529
691
  time.sleep(self.polling_interval)
530
692
 
531
- raise MaxRetriesReachedError(self.max_retries)
693
+ raise MaxRetriesReachedError(job_id, self.max_retries)
@@ -0,0 +1,94 @@
1
+ # SPDX-FileCopyrightText: 2025 Qoro Quantum Ltd <divi@qoroquantum.de>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Data models for Quantum Processing Units (QPUs) and QPUSystems."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field, replace
10
+
11
+ _AVAILABLE_QPU_SYSTEMS: dict[str, QPUSystem] = {}
12
+
13
+
14
+ @dataclass(frozen=True, repr=True)
15
+ class QPU:
16
+ """Represents a single Quantum Processing Unit (QPU).
17
+
18
+ Attributes:
19
+ nickname: The unique name or identifier for the QPU.
20
+ q_bits: The number of qubits in the QPU.
21
+ status: The current operational status of the QPU.
22
+ system_kind: The type of technology the QPU uses.
23
+ """
24
+
25
+ nickname: str
26
+ q_bits: int
27
+ status: str
28
+ system_kind: str
29
+
30
+
31
+ @dataclass(frozen=True, repr=True)
32
+ class QPUSystem:
33
+ """Represents a collection of QPUs that form a quantum computing system.
34
+
35
+ Attributes:
36
+ name: The name of the QPU system.
37
+ qpus: A list of QPU objects that are part of this system.
38
+ access_level: The access level granted to the user for this system (e.g., 'PUBLIC').
39
+ supports_expval: Whether the system supports expectation value jobs.
40
+ """
41
+
42
+ name: str
43
+ qpus: list[QPU] = field(default_factory=list)
44
+ access_level: str = "PUBLIC"
45
+ supports_expval: bool = False
46
+
47
+
48
+ def parse_qpu_systems(json_data: list) -> list[QPUSystem]:
49
+ """Parses a list of QPU system data from JSON into QPUSystem objects."""
50
+ return [
51
+ QPUSystem(
52
+ name=system_data["name"],
53
+ qpus=[QPU(**qpu) for qpu in system_data.get("qpus", [])],
54
+ access_level=system_data["access_level"],
55
+ )
56
+ for system_data in json_data
57
+ ]
58
+
59
+
60
+ def update_qpu_systems_cache(systems: list[QPUSystem]):
61
+ """Updates the cache of available QPU systems."""
62
+ _AVAILABLE_QPU_SYSTEMS.clear()
63
+ for system in systems:
64
+ if system.name == "qoro_maestro":
65
+ system = replace(system, supports_expval=True)
66
+ _AVAILABLE_QPU_SYSTEMS[system.name] = system
67
+
68
+
69
+ def get_qpu_system(name: str) -> QPUSystem:
70
+ """
71
+ Get a QPUSystem object by its name from the cache.
72
+
73
+ Args:
74
+ name: The name of the QPU system to retrieve.
75
+
76
+ Returns:
77
+ The QPUSystem object with the matching name.
78
+
79
+ Raises:
80
+ ValueError: If the cache is empty or the system is not found.
81
+ """
82
+ if not _AVAILABLE_QPU_SYSTEMS:
83
+ raise ValueError(
84
+ "QPU systems cache is empty. Call `QoroService.fetch_qpu_systems()` to populate it."
85
+ )
86
+ try:
87
+ return _AVAILABLE_QPU_SYSTEMS[name]
88
+ except KeyError:
89
+ raise ValueError(f"QPUSystem with name '{name}' not found in cache.") from None
90
+
91
+
92
+ def get_available_qpu_systems() -> list[QPUSystem]:
93
+ """Returns a list of all available QPU systems from the cache."""
94
+ return list(_AVAILABLE_QPU_SYSTEMS.values())