qoro-divi 0.2.0b1__py3-none-any.whl → 0.6.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.
Files changed (92) hide show
  1. divi/__init__.py +1 -2
  2. divi/backends/__init__.py +10 -0
  3. divi/backends/_backend_properties_conversion.py +227 -0
  4. divi/backends/_circuit_runner.py +70 -0
  5. divi/backends/_execution_result.py +70 -0
  6. divi/backends/_parallel_simulator.py +486 -0
  7. divi/backends/_qoro_service.py +663 -0
  8. divi/backends/_qpu_system.py +101 -0
  9. divi/backends/_results_processing.py +133 -0
  10. divi/circuits/__init__.py +13 -0
  11. divi/{exp/cirq → circuits/_cirq}/__init__.py +1 -2
  12. divi/circuits/_cirq/_parser.py +110 -0
  13. divi/circuits/_cirq/_qasm_export.py +78 -0
  14. divi/circuits/_core.py +391 -0
  15. divi/{qasm.py → circuits/_qasm_conversion.py} +73 -14
  16. divi/circuits/_qasm_validation.py +694 -0
  17. divi/qprog/__init__.py +27 -8
  18. divi/qprog/_expectation.py +181 -0
  19. divi/qprog/_hamiltonians.py +281 -0
  20. divi/qprog/algorithms/__init__.py +16 -0
  21. divi/qprog/algorithms/_ansatze.py +368 -0
  22. divi/qprog/algorithms/_custom_vqa.py +263 -0
  23. divi/qprog/algorithms/_pce.py +262 -0
  24. divi/qprog/algorithms/_qaoa.py +579 -0
  25. divi/qprog/algorithms/_vqe.py +262 -0
  26. divi/qprog/batch.py +387 -74
  27. divi/qprog/checkpointing.py +556 -0
  28. divi/qprog/exceptions.py +9 -0
  29. divi/qprog/optimizers.py +1014 -43
  30. divi/qprog/quantum_program.py +243 -412
  31. divi/qprog/typing.py +62 -0
  32. divi/qprog/variational_quantum_algorithm.py +1208 -0
  33. divi/qprog/workflows/__init__.py +10 -0
  34. divi/qprog/{_graph_partitioning.py → workflows/_graph_partitioning.py} +139 -95
  35. divi/qprog/workflows/_qubo_partitioning.py +221 -0
  36. divi/qprog/workflows/_vqe_sweep.py +560 -0
  37. divi/reporting/__init__.py +7 -0
  38. divi/reporting/_pbar.py +127 -0
  39. divi/reporting/_qlogger.py +68 -0
  40. divi/reporting/_reporter.py +155 -0
  41. {qoro_divi-0.2.0b1.dist-info → qoro_divi-0.6.0.dist-info}/METADATA +43 -15
  42. qoro_divi-0.6.0.dist-info/RECORD +47 -0
  43. {qoro_divi-0.2.0b1.dist-info → qoro_divi-0.6.0.dist-info}/WHEEL +1 -1
  44. qoro_divi-0.6.0.dist-info/licenses/LICENSES/.license-header +3 -0
  45. divi/_pbar.py +0 -73
  46. divi/circuits.py +0 -139
  47. divi/exp/cirq/_lexer.py +0 -126
  48. divi/exp/cirq/_parser.py +0 -889
  49. divi/exp/cirq/_qasm_export.py +0 -37
  50. divi/exp/cirq/_qasm_import.py +0 -35
  51. divi/exp/cirq/exception.py +0 -21
  52. divi/exp/scipy/_cobyla.py +0 -342
  53. divi/exp/scipy/pyprima/LICENCE.txt +0 -28
  54. divi/exp/scipy/pyprima/__init__.py +0 -263
  55. divi/exp/scipy/pyprima/cobyla/__init__.py +0 -0
  56. divi/exp/scipy/pyprima/cobyla/cobyla.py +0 -599
  57. divi/exp/scipy/pyprima/cobyla/cobylb.py +0 -849
  58. divi/exp/scipy/pyprima/cobyla/geometry.py +0 -240
  59. divi/exp/scipy/pyprima/cobyla/initialize.py +0 -269
  60. divi/exp/scipy/pyprima/cobyla/trustregion.py +0 -540
  61. divi/exp/scipy/pyprima/cobyla/update.py +0 -331
  62. divi/exp/scipy/pyprima/common/__init__.py +0 -0
  63. divi/exp/scipy/pyprima/common/_bounds.py +0 -41
  64. divi/exp/scipy/pyprima/common/_linear_constraints.py +0 -46
  65. divi/exp/scipy/pyprima/common/_nonlinear_constraints.py +0 -64
  66. divi/exp/scipy/pyprima/common/_project.py +0 -224
  67. divi/exp/scipy/pyprima/common/checkbreak.py +0 -107
  68. divi/exp/scipy/pyprima/common/consts.py +0 -48
  69. divi/exp/scipy/pyprima/common/evaluate.py +0 -101
  70. divi/exp/scipy/pyprima/common/history.py +0 -39
  71. divi/exp/scipy/pyprima/common/infos.py +0 -30
  72. divi/exp/scipy/pyprima/common/linalg.py +0 -452
  73. divi/exp/scipy/pyprima/common/message.py +0 -336
  74. divi/exp/scipy/pyprima/common/powalg.py +0 -131
  75. divi/exp/scipy/pyprima/common/preproc.py +0 -393
  76. divi/exp/scipy/pyprima/common/present.py +0 -5
  77. divi/exp/scipy/pyprima/common/ratio.py +0 -56
  78. divi/exp/scipy/pyprima/common/redrho.py +0 -49
  79. divi/exp/scipy/pyprima/common/selectx.py +0 -346
  80. divi/interfaces.py +0 -25
  81. divi/parallel_simulator.py +0 -258
  82. divi/qlogger.py +0 -119
  83. divi/qoro_service.py +0 -343
  84. divi/qprog/_mlae.py +0 -182
  85. divi/qprog/_qaoa.py +0 -440
  86. divi/qprog/_vqe.py +0 -275
  87. divi/qprog/_vqe_sweep.py +0 -144
  88. divi/utils.py +0 -116
  89. qoro_divi-0.2.0b1.dist-info/RECORD +0 -58
  90. /divi/{qem.py → circuits/qem.py} +0 -0
  91. {qoro_divi-0.2.0b1.dist-info → qoro_divi-0.6.0.dist-info/licenses}/LICENSE +0 -0
  92. {qoro_divi-0.2.0b1.dist-info → qoro_divi-0.6.0.dist-info/licenses}/LICENSES/Apache-2.0.txt +0 -0
@@ -0,0 +1,486 @@
1
+ # SPDX-FileCopyrightText: 2025-2026 Qoro Quantum Ltd <divi@qoroquantum.de>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import bisect
6
+ import heapq
7
+ import logging
8
+ import os
9
+ import threading
10
+ from functools import partial
11
+ from multiprocessing import Pool, current_process
12
+ from typing import Any, Literal
13
+ from warnings import warn
14
+
15
+ from qiskit import QuantumCircuit, transpile
16
+ from qiskit.converters import circuit_to_dag
17
+ from qiskit.dagcircuit import DAGOpNode
18
+ from qiskit.providers import Backend
19
+ from qiskit.transpiler.exceptions import TranspilerError
20
+ from qiskit_aer import AerSimulator
21
+ from qiskit_aer.noise import NoiseModel
22
+
23
+ from divi.backends import CircuitRunner
24
+ from divi.backends._execution_result import ExecutionResult
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Suppress stevedore extension loading errors (harmless Qiskit v2/provider issue)
29
+ _stevedore_logger = logging.getLogger("stevedore.extension")
30
+ _stevedore_logger.setLevel(logging.CRITICAL)
31
+
32
+ # Lazy-loaded fake backends dictionary
33
+ _FAKE_BACKENDS_CACHE: dict[int, list] | None = None
34
+
35
+
36
+ def _load_fake_backends() -> dict[int, list]:
37
+ """Lazy load and return the FAKE_BACKENDS dictionary."""
38
+ global _FAKE_BACKENDS_CACHE
39
+ if _FAKE_BACKENDS_CACHE is None:
40
+ # Import only when actually needed
41
+ import qiskit_ibm_runtime.fake_provider as fk_prov
42
+
43
+ _FAKE_BACKENDS_CACHE = {
44
+ 5: [
45
+ fk_prov.FakeManilaV2,
46
+ fk_prov.FakeBelemV2,
47
+ fk_prov.FakeLimaV2,
48
+ fk_prov.FakeQuitoV2,
49
+ ],
50
+ 7: [
51
+ fk_prov.FakeOslo,
52
+ fk_prov.FakePerth,
53
+ fk_prov.FakeLagosV2,
54
+ fk_prov.FakeNairobiV2,
55
+ ],
56
+ 15: [fk_prov.FakeMelbourneV2],
57
+ 16: [fk_prov.FakeGuadalupeV2],
58
+ 20: [
59
+ fk_prov.FakeAlmadenV2,
60
+ fk_prov.FakeJohannesburgV2,
61
+ fk_prov.FakeSingaporeV2,
62
+ fk_prov.FakeBoeblingenV2,
63
+ ],
64
+ 27: [
65
+ fk_prov.FakeGeneva,
66
+ fk_prov.FakePeekskill,
67
+ fk_prov.FakeAuckland,
68
+ fk_prov.FakeCairoV2,
69
+ ],
70
+ }
71
+ return _FAKE_BACKENDS_CACHE
72
+
73
+
74
+ def _find_best_fake_backend(circuit: QuantumCircuit) -> list[type] | None:
75
+ """Find the best fake backend for a given circuit based on qubit count.
76
+
77
+ Args:
78
+ circuit: QuantumCircuit to find a backend for.
79
+
80
+ Returns:
81
+ List of fake backend classes that support the circuit's qubit count, or None.
82
+ """
83
+ fake_backends = _load_fake_backends()
84
+ keys = sorted(fake_backends.keys())
85
+ pos = bisect.bisect_left(keys, circuit.num_qubits)
86
+ return fake_backends[keys[pos]] if pos < len(keys) else None
87
+
88
+
89
+ # Public API for backward compatibility with tests
90
+ def __getattr__(name: str):
91
+ """Lazy load FAKE_BACKENDS when accessed."""
92
+ if name == "FAKE_BACKENDS":
93
+ return _load_fake_backends()
94
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
95
+
96
+
97
+ def _default_n_processes() -> int:
98
+ """Get a reasonable default number of processes based on CPU count.
99
+
100
+ Uses most available CPU cores (all minus 1, or 3/4 if many cores), with a
101
+ minimum of 2 and maximum of 16. This provides good parallelism while leaving
102
+ one core free for system processes.
103
+
104
+ If running in a different thread or process (not the main thread/process),
105
+ limits to 2 cores to avoid resource contention.
106
+
107
+ Returns:
108
+ int: Default number of processes to use.
109
+ """
110
+ # Check if we're running in a worker thread or subprocess
111
+ is_main_thread = threading.current_thread() is threading.main_thread()
112
+ is_main_process = current_process().name == "MainProcess"
113
+
114
+ if not (is_main_thread and is_main_process):
115
+ # Running in a different thread/process - limit to 2 cores
116
+ return 2
117
+
118
+ cpu_count = os.cpu_count() or 4
119
+ if cpu_count <= 4:
120
+ # For small systems, use all but 1 core
121
+ return max(2, cpu_count - 1)
122
+ elif cpu_count <= 16:
123
+ # For medium systems, use all but 1 core
124
+ return cpu_count - 1
125
+ else:
126
+ # For large systems, use 3/4 of cores, capped at 16
127
+ return min(16, int(cpu_count * 0.75))
128
+
129
+
130
+ class ParallelSimulator(CircuitRunner):
131
+ def __init__(
132
+ self,
133
+ n_processes: int | None = None,
134
+ shots: int = 5000,
135
+ simulation_seed: int | None = None,
136
+ qiskit_backend: Backend | Literal["auto"] | None = None,
137
+ noise_model: NoiseModel | None = None,
138
+ _deterministic_execution: bool = False,
139
+ ):
140
+ """
141
+ A parallel wrapper around Qiskit's AerSimulator using Qiskit's built-in parallelism.
142
+
143
+ Args:
144
+ n_processes (int | None, optional): Number of parallel processes to use for transpilation and
145
+ simulation. If None, defaults to half the available CPU cores (min 2, max 8).
146
+ Controls both transpilation parallelism and execution parallelism. The execution
147
+ parallelism mode (circuit or shot) is automatically selected based on workload
148
+ characteristics.
149
+ shots (int, optional): Number of shots to perform. Defaults to 5000.
150
+ simulation_seed (int, optional): Seed for the random number generator to ensure reproducibility. Defaults to None.
151
+ qiskit_backend (Backend | Literal["auto"] | None, optional): A Qiskit backend to initiate the simulator from.
152
+ If "auto" is passed, the best-fit most recent fake backend will be chosen for the given circuit.
153
+ Defaults to None, resulting in noiseless simulation.
154
+ noise_model (NoiseModel, optional): Qiskit noise model to use in simulation. Defaults to None.
155
+ """
156
+ super().__init__(shots=shots)
157
+
158
+ if qiskit_backend and noise_model:
159
+ warn(
160
+ "Both `qiskit_backend` and `noise_model` have been provided."
161
+ " `noise_model` will be ignored and the model from the backend will be used instead."
162
+ )
163
+
164
+ if n_processes is None:
165
+ n_processes = _default_n_processes()
166
+ elif n_processes < 1:
167
+ raise ValueError(f"n_processes must be >= 1, got {n_processes}")
168
+ self._n_processes = n_processes
169
+ self.simulation_seed = simulation_seed
170
+ self.qiskit_backend = qiskit_backend
171
+ self.noise_model = noise_model
172
+ self._deterministic_execution = _deterministic_execution
173
+
174
+ def set_seed(self, seed: int):
175
+ """
176
+ Set the random seed for circuit simulation.
177
+
178
+ Args:
179
+ seed (int): Seed value for the random number generator used in simulation.
180
+ """
181
+ self.simulation_seed = seed
182
+
183
+ @property
184
+ def n_processes(self) -> int:
185
+ """
186
+ Get the current number of parallel processes.
187
+
188
+ Returns:
189
+ int: Number of parallel processes configured.
190
+ """
191
+ return self._n_processes
192
+
193
+ @n_processes.setter
194
+ def n_processes(self, value: int):
195
+ """
196
+ Set the number of parallel processes (>= 1).
197
+
198
+ Controls:
199
+ - Transpilation parallelism
200
+ - OpenMP thread limit
201
+ - Circuit/Shot parallelism (auto-selected based on workload)
202
+ """
203
+ if value < 1:
204
+ raise ValueError(f"n_processes must be >= 1, got {value}")
205
+ self._n_processes = value
206
+
207
+ @property
208
+ def supports_expval(self) -> bool:
209
+ """
210
+ Whether the backend supports expectation value measurements.
211
+ """
212
+ return False
213
+
214
+ @property
215
+ def is_async(self) -> bool:
216
+ """
217
+ Whether the backend executes circuits asynchronously.
218
+ """
219
+ return False
220
+
221
+ def _resolve_backend(self, circuit: QuantumCircuit | None = None) -> Backend | None:
222
+ """Resolve the backend from qiskit_backend setting."""
223
+ if self.qiskit_backend == "auto":
224
+ if circuit is None:
225
+ raise ValueError(
226
+ "Circuit must be provided when qiskit_backend is 'auto'"
227
+ )
228
+ backend_list = _find_best_fake_backend(circuit)
229
+ if backend_list is None:
230
+ raise ValueError(
231
+ f"No fake backend available for circuit with {circuit.num_qubits} qubits. "
232
+ "Please provide an explicit backend or use a smaller circuit."
233
+ )
234
+ return backend_list[-1]()
235
+ return self.qiskit_backend
236
+
237
+ def _create_simulator(self, resolved_backend: Backend | None) -> AerSimulator:
238
+ """Create an AerSimulator instance from a resolved backend or noise model."""
239
+ return (
240
+ AerSimulator.from_backend(resolved_backend)
241
+ if resolved_backend is not None
242
+ else AerSimulator(noise_model=self.noise_model)
243
+ )
244
+
245
+ def _execute_circuits_deterministically(
246
+ self,
247
+ circuit_labels: list[str],
248
+ transpiled_circuits: list[QuantumCircuit],
249
+ resolved_backend: Backend | None,
250
+ ) -> list[dict[str, Any]]:
251
+ """
252
+ Execute circuits individually for debugging purposes.
253
+
254
+ This method ensures deterministic results by running each circuit with its own
255
+ simulator instance and the same seed. Used internally for debugging non-deterministic
256
+ behavior in batch execution.
257
+
258
+ Args:
259
+ circuit_labels: List of circuit labels
260
+ transpiled_circuits: List of transpiled QuantumCircuit objects
261
+ resolved_backend: Resolved backend for simulator creation
262
+
263
+ Returns:
264
+ List of result dictionaries
265
+ """
266
+ results = []
267
+ for i, (label, transpiled_circuit) in enumerate(
268
+ zip(circuit_labels, transpiled_circuits)
269
+ ):
270
+ # Create a new simulator instance for each circuit with the same seed
271
+ circuit_simulator = self._create_simulator(resolved_backend)
272
+
273
+ if self.simulation_seed is not None:
274
+ circuit_simulator.set_options(seed_simulator=self.simulation_seed)
275
+
276
+ # Run the single circuit
277
+ job = circuit_simulator.run(transpiled_circuit, shots=self.shots)
278
+ circuit_result = job.result()
279
+ counts = circuit_result.get_counts(0)
280
+ results.append({"label": label, "results": dict(counts)})
281
+
282
+ return results
283
+
284
+ def _configure_simulator_parallelism(
285
+ self, aer_simulator: AerSimulator, num_circuits: int
286
+ ):
287
+ """Configure AerSimulator parallelism options based on workload."""
288
+ if self.simulation_seed is not None:
289
+ aer_simulator.set_options(seed_simulator=self.simulation_seed)
290
+
291
+ # Default to utilizing all allocated processes for threads
292
+ options = {"max_parallel_threads": self.n_processes}
293
+
294
+ if num_circuits > 1:
295
+ # Batch mode: parallelize experiments
296
+ options.update(
297
+ {
298
+ "max_parallel_experiments": min(num_circuits, self.n_processes),
299
+ "max_parallel_shots": 1,
300
+ }
301
+ )
302
+ elif self.shots >= self.n_processes:
303
+ # Single circuit, high shots: parallelize shots
304
+ options.update(
305
+ {
306
+ "max_parallel_experiments": 1,
307
+ "max_parallel_shots": self.n_processes,
308
+ }
309
+ )
310
+ else:
311
+ # Single circuit, low shots: default behavior (usually serial shots)
312
+ options.update(
313
+ {
314
+ "max_parallel_experiments": 1,
315
+ "max_parallel_shots": 1,
316
+ }
317
+ )
318
+
319
+ aer_simulator.set_options(**options)
320
+
321
+ def submit_circuits(self, circuits: dict[str, str]) -> ExecutionResult:
322
+ """
323
+ Submit multiple circuits for parallel simulation using Qiskit's built-in parallelism.
324
+
325
+ Uses Qiskit's native batch transpilation and execution, which handles parallelism
326
+ internally.
327
+
328
+ Args:
329
+ circuits (dict[str, str]): Dictionary mapping circuit labels to OpenQASM
330
+ string representations.
331
+
332
+ Returns:
333
+ ExecutionResult: Contains results directly (synchronous execution).
334
+ Results are in the format: [{"label": str, "results": dict}, ...]
335
+ """
336
+ logger.debug(
337
+ f"Simulating {len(circuits)} circuits with {self.n_processes} processes"
338
+ )
339
+
340
+ # 1. Parse Circuits
341
+ circuit_labels = list(circuits.keys())
342
+ qiskit_circuits = [
343
+ QuantumCircuit.from_qasm_str(qasm) for qasm in circuits.values()
344
+ ]
345
+
346
+ # 2. Resolve Backend
347
+ if self.qiskit_backend == "auto":
348
+ max_qubits_circ = max(qiskit_circuits, key=lambda x: x.num_qubits)
349
+ resolved_backend = self._resolve_backend(max_qubits_circ)
350
+ else:
351
+ resolved_backend = self._resolve_backend()
352
+
353
+ # 3. Configure Simulator
354
+ aer_simulator = self._create_simulator(resolved_backend)
355
+ self._configure_simulator_parallelism(aer_simulator, len(qiskit_circuits))
356
+
357
+ # 4. Transpile
358
+ transpiled_circuits = transpile(
359
+ qiskit_circuits, aer_simulator, num_processes=self.n_processes
360
+ )
361
+
362
+ # 5. Execute
363
+ if self._deterministic_execution:
364
+ results = self._execute_circuits_deterministically(
365
+ circuit_labels, transpiled_circuits, resolved_backend
366
+ )
367
+ return ExecutionResult(results=results)
368
+
369
+ job = aer_simulator.run(transpiled_circuits, shots=self.shots)
370
+ batch_result = job.result()
371
+
372
+ # Check for non-determinism warnings
373
+ metadata = batch_result.metadata
374
+ if (
375
+ parallel_experiments := metadata.get("parallel_experiments", 1)
376
+ ) > 1 and self.simulation_seed is not None:
377
+ omp_nested = metadata.get("omp_nested", False)
378
+ logger.warning(
379
+ f"Parallel execution detected (parallel_experiments={parallel_experiments}, "
380
+ f"omp_nested={omp_nested}). Results may not be deterministic across different "
381
+ "grouping strategies. Consider enabling deterministic mode for "
382
+ "deterministic results."
383
+ )
384
+
385
+ # 6. Format Results
386
+ results = [
387
+ {"label": label, "results": dict(batch_result.get_counts(i))}
388
+ for i, label in enumerate(circuit_labels)
389
+ ]
390
+ return ExecutionResult(results=results)
391
+
392
+ @staticmethod
393
+ def estimate_run_time_single_circuit(
394
+ circuit: str,
395
+ qiskit_backend: Backend | Literal["auto"],
396
+ **transpilation_kwargs,
397
+ ) -> float:
398
+ """
399
+ Estimate the execution time of a quantum circuit on a given backend, accounting for parallel gate execution.
400
+
401
+ Parameters:
402
+ circuit: The quantum circuit to estimate execution time for as a QASM string.
403
+ qiskit_backend: A Qiskit backend to use for gate time estimation.
404
+
405
+ Returns:
406
+ float: Estimated execution time in seconds.
407
+ """
408
+ qiskit_circuit = QuantumCircuit.from_qasm_str(circuit)
409
+
410
+ if qiskit_backend == "auto":
411
+ if not (backend_list := _find_best_fake_backend(qiskit_circuit)):
412
+ raise ValueError(
413
+ f"No fake backend available for circuit with {qiskit_circuit.num_qubits} qubits. "
414
+ "Please provide an explicit backend or use a smaller circuit."
415
+ )
416
+ resolved_backend = backend_list[-1]()
417
+ else:
418
+ resolved_backend = qiskit_backend
419
+
420
+ transpiled_circuit = transpile(
421
+ qiskit_circuit, resolved_backend, **transpilation_kwargs
422
+ )
423
+
424
+ total_run_time_s = 0.0
425
+ durations = resolved_backend.target.durations()
426
+
427
+ for node in circuit_to_dag(transpiled_circuit).longest_path():
428
+ if not isinstance(node, DAGOpNode) or not node.num_qubits:
429
+ continue
430
+
431
+ try:
432
+ idx = tuple(q._index for q in node.qargs)
433
+ duration = durations.get(node.name, idx, unit="s")
434
+ total_run_time_s += duration
435
+ except TranspilerError:
436
+ if node.name != "barrier":
437
+ warn(f"Instruction duration not found: {node.name}")
438
+
439
+ return total_run_time_s
440
+
441
+ @staticmethod
442
+ def estimate_run_time_batch(
443
+ circuits: list[str] | None = None,
444
+ precomputed_durations: list[float] | None = None,
445
+ n_qpus: int = 5,
446
+ **transpilation_kwargs,
447
+ ) -> float:
448
+ """
449
+ Estimate the execution time of a quantum circuit on a given backend, accounting for parallel gate execution.
450
+
451
+ Parameters:
452
+ circuits (list[str]): The quantum circuits to estimate execution time for, as QASM strings.
453
+ precomputed_durations (list[float]): A list of precomputed durations to use.
454
+ n_qpus (int): Number of QPU nodes in the pre-supposed cluster we are estimating runtime against.
455
+
456
+ Returns:
457
+ float: Estimated execution time in seconds.
458
+ """
459
+
460
+ # Compute the run time estimates for each given circuit, in descending order
461
+ if precomputed_durations is None:
462
+ with Pool() as p:
463
+ estimated_run_times = p.map(
464
+ partial(
465
+ ParallelSimulator.estimate_run_time_single_circuit,
466
+ qiskit_backend="auto",
467
+ **transpilation_kwargs,
468
+ ),
469
+ circuits,
470
+ )
471
+ estimated_run_times_sorted = sorted(estimated_run_times, reverse=True)
472
+ else:
473
+ estimated_run_times_sorted = sorted(precomputed_durations, reverse=True)
474
+
475
+ # Optimization for trivial case
476
+ if n_qpus >= len(estimated_run_times_sorted):
477
+ return estimated_run_times_sorted[0] if estimated_run_times_sorted else 0.0
478
+
479
+ # LPT (Longest Processing Time) scheduling using a min-heap of processor finish times
480
+ processor_finish_times = [0.0] * n_qpus
481
+ for run_time in estimated_run_times_sorted:
482
+ heapq.heappush(
483
+ processor_finish_times, heapq.heappop(processor_finish_times) + run_time
484
+ )
485
+
486
+ return max(processor_finish_times)