qoro-divi 0.2.0b1__py3-none-any.whl → 0.5.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 (88) hide show
  1. divi/__init__.py +1 -2
  2. divi/backends/__init__.py +9 -0
  3. divi/backends/_circuit_runner.py +70 -0
  4. divi/backends/_execution_result.py +70 -0
  5. divi/backends/_parallel_simulator.py +486 -0
  6. divi/backends/_qoro_service.py +663 -0
  7. divi/backends/_qpu_system.py +101 -0
  8. divi/backends/_results_processing.py +133 -0
  9. divi/circuits/__init__.py +8 -0
  10. divi/{exp/cirq → circuits/_cirq}/__init__.py +1 -2
  11. divi/circuits/_cirq/_parser.py +110 -0
  12. divi/circuits/_cirq/_qasm_export.py +78 -0
  13. divi/circuits/_core.py +369 -0
  14. divi/{qasm.py → circuits/_qasm_conversion.py} +73 -14
  15. divi/circuits/_qasm_validation.py +694 -0
  16. divi/qprog/__init__.py +24 -6
  17. divi/qprog/_expectation.py +181 -0
  18. divi/qprog/_hamiltonians.py +281 -0
  19. divi/qprog/algorithms/__init__.py +14 -0
  20. divi/qprog/algorithms/_ansatze.py +356 -0
  21. divi/qprog/algorithms/_qaoa.py +572 -0
  22. divi/qprog/algorithms/_vqe.py +249 -0
  23. divi/qprog/batch.py +383 -73
  24. divi/qprog/checkpointing.py +556 -0
  25. divi/qprog/exceptions.py +9 -0
  26. divi/qprog/optimizers.py +1014 -43
  27. divi/qprog/quantum_program.py +231 -413
  28. divi/qprog/variational_quantum_algorithm.py +995 -0
  29. divi/qprog/workflows/__init__.py +10 -0
  30. divi/qprog/{_graph_partitioning.py → workflows/_graph_partitioning.py} +139 -95
  31. divi/qprog/workflows/_qubo_partitioning.py +220 -0
  32. divi/qprog/workflows/_vqe_sweep.py +560 -0
  33. divi/reporting/__init__.py +7 -0
  34. divi/reporting/_pbar.py +127 -0
  35. divi/reporting/_qlogger.py +68 -0
  36. divi/reporting/_reporter.py +133 -0
  37. {qoro_divi-0.2.0b1.dist-info → qoro_divi-0.5.0.dist-info}/METADATA +43 -15
  38. qoro_divi-0.5.0.dist-info/RECORD +43 -0
  39. {qoro_divi-0.2.0b1.dist-info → qoro_divi-0.5.0.dist-info}/WHEEL +1 -1
  40. qoro_divi-0.5.0.dist-info/licenses/LICENSES/.license-header +3 -0
  41. divi/_pbar.py +0 -73
  42. divi/circuits.py +0 -139
  43. divi/exp/cirq/_lexer.py +0 -126
  44. divi/exp/cirq/_parser.py +0 -889
  45. divi/exp/cirq/_qasm_export.py +0 -37
  46. divi/exp/cirq/_qasm_import.py +0 -35
  47. divi/exp/cirq/exception.py +0 -21
  48. divi/exp/scipy/_cobyla.py +0 -342
  49. divi/exp/scipy/pyprima/LICENCE.txt +0 -28
  50. divi/exp/scipy/pyprima/__init__.py +0 -263
  51. divi/exp/scipy/pyprima/cobyla/__init__.py +0 -0
  52. divi/exp/scipy/pyprima/cobyla/cobyla.py +0 -599
  53. divi/exp/scipy/pyprima/cobyla/cobylb.py +0 -849
  54. divi/exp/scipy/pyprima/cobyla/geometry.py +0 -240
  55. divi/exp/scipy/pyprima/cobyla/initialize.py +0 -269
  56. divi/exp/scipy/pyprima/cobyla/trustregion.py +0 -540
  57. divi/exp/scipy/pyprima/cobyla/update.py +0 -331
  58. divi/exp/scipy/pyprima/common/__init__.py +0 -0
  59. divi/exp/scipy/pyprima/common/_bounds.py +0 -41
  60. divi/exp/scipy/pyprima/common/_linear_constraints.py +0 -46
  61. divi/exp/scipy/pyprima/common/_nonlinear_constraints.py +0 -64
  62. divi/exp/scipy/pyprima/common/_project.py +0 -224
  63. divi/exp/scipy/pyprima/common/checkbreak.py +0 -107
  64. divi/exp/scipy/pyprima/common/consts.py +0 -48
  65. divi/exp/scipy/pyprima/common/evaluate.py +0 -101
  66. divi/exp/scipy/pyprima/common/history.py +0 -39
  67. divi/exp/scipy/pyprima/common/infos.py +0 -30
  68. divi/exp/scipy/pyprima/common/linalg.py +0 -452
  69. divi/exp/scipy/pyprima/common/message.py +0 -336
  70. divi/exp/scipy/pyprima/common/powalg.py +0 -131
  71. divi/exp/scipy/pyprima/common/preproc.py +0 -393
  72. divi/exp/scipy/pyprima/common/present.py +0 -5
  73. divi/exp/scipy/pyprima/common/ratio.py +0 -56
  74. divi/exp/scipy/pyprima/common/redrho.py +0 -49
  75. divi/exp/scipy/pyprima/common/selectx.py +0 -346
  76. divi/interfaces.py +0 -25
  77. divi/parallel_simulator.py +0 -258
  78. divi/qlogger.py +0 -119
  79. divi/qoro_service.py +0 -343
  80. divi/qprog/_mlae.py +0 -182
  81. divi/qprog/_qaoa.py +0 -440
  82. divi/qprog/_vqe.py +0 -275
  83. divi/qprog/_vqe_sweep.py +0 -144
  84. divi/utils.py +0 -116
  85. qoro_divi-0.2.0b1.dist-info/RECORD +0 -58
  86. /divi/{qem.py → circuits/qem.py} +0 -0
  87. {qoro_divi-0.2.0b1.dist-info → qoro_divi-0.5.0.dist-info/licenses}/LICENSE +0 -0
  88. {qoro_divi-0.2.0b1.dist-info → qoro_divi-0.5.0.dist-info/licenses}/LICENSES/Apache-2.0.txt +0 -0
divi/circuits/_core.py ADDED
@@ -0,0 +1,369 @@
1
+ # SPDX-FileCopyrightText: 2025 Qoro Quantum Ltd <divi@qoroquantum.de>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from itertools import product
8
+ from typing import Callable, Literal
9
+
10
+ import dill
11
+ import numpy as np
12
+ import numpy.typing as npt
13
+ import pennylane as qml
14
+ from pennylane.transforms.core.transform_program import TransformProgram
15
+
16
+ from divi.circuits import to_openqasm
17
+ from divi.circuits.qem import QEMProtocol
18
+
19
+ TRANSFORM_PROGRAM = TransformProgram()
20
+ TRANSFORM_PROGRAM.add_transform(qml.transforms.split_to_single_terms)
21
+
22
+
23
+ def _wire_grouping(measurements: list[qml.measurements.MeasurementProcess]):
24
+ """
25
+ Groups a list of PennyLane MeasurementProcess objects by mutually non-overlapping wires.
26
+
27
+ Each group contains measurements whose wires do not overlap with those of any other
28
+ measurement in the same group. This enables parallel measurement of compatible observables,
29
+ e.g., for grouped execution or more efficient sampling.
30
+
31
+ Returns:
32
+ partition_indices (list[list[int]]): Indices of the original measurements in each group.
33
+ mp_groups (list[list[MeasurementProcess]]): Grouped MeasurementProcess objects.
34
+ """
35
+ mp_groups = []
36
+ wires_for_each_group = []
37
+ group_mapping = {} # original_index -> (group_idx, pos_in_group)
38
+
39
+ for i, mp in enumerate(measurements):
40
+ added = False
41
+ for group_idx, wires in enumerate(wires_for_each_group):
42
+ if not qml.wires.Wires.shared_wires([wires, mp.wires]):
43
+ mp_groups[group_idx].append(mp)
44
+ wires_for_each_group[group_idx] += mp.wires
45
+ group_mapping[i] = (group_idx, len(mp_groups[group_idx]) - 1)
46
+ added = True
47
+ break
48
+ if not added:
49
+ mp_groups.append([mp])
50
+ wires_for_each_group.append(mp.wires)
51
+ group_mapping[i] = (len(mp_groups) - 1, 0)
52
+
53
+ partition_indices = [[] for _ in range(len(mp_groups))]
54
+ for original_idx, (group_idx, _) in group_mapping.items():
55
+ partition_indices[group_idx].append(original_idx)
56
+
57
+ return partition_indices, mp_groups
58
+
59
+
60
+ def _create_final_postprocessing_fn(coefficients, partition_indices, num_total_obs):
61
+ """Create a wrapper fn that reconstructs the flat results list and computes the final energy."""
62
+ reverse_map = [None] * num_total_obs
63
+ for group_idx, indices_in_group in enumerate(partition_indices):
64
+ for idx_within_group, original_flat_idx in enumerate(indices_in_group):
65
+ reverse_map[original_flat_idx] = (group_idx, idx_within_group)
66
+
67
+ missing_indices = [i for i, v in enumerate(reverse_map) if v is None]
68
+ if missing_indices:
69
+ raise RuntimeError(
70
+ f"partition_indices does not cover all observable indices. Missing indices: {missing_indices}"
71
+ )
72
+
73
+ def final_postprocessing_fn(grouped_results):
74
+ """
75
+ Takes grouped results, flattens them to the original order,
76
+ multiplies by coefficients, and sums to get the final energy.
77
+ """
78
+ if len(grouped_results) != len(partition_indices):
79
+ raise RuntimeError(
80
+ f"Expected {len(partition_indices)} grouped results, but got {len(grouped_results)}."
81
+ )
82
+ flat_results = np.zeros(num_total_obs, dtype=np.float64)
83
+ for original_flat_idx in range(num_total_obs):
84
+ group_idx, idx_within_group = reverse_map[original_flat_idx]
85
+
86
+ group_result = grouped_results[group_idx]
87
+ # When a group has one measurement, the result is a scalar.
88
+ if len(partition_indices[group_idx]) == 1:
89
+ flat_results[original_flat_idx] = group_result
90
+ else:
91
+ flat_results[original_flat_idx] = group_result[idx_within_group]
92
+
93
+ # Perform the final summation using the efficient dot product method.
94
+ return np.dot(coefficients, flat_results)
95
+
96
+ return final_postprocessing_fn
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class ExecutableQASMCircuit:
101
+ """Represents a single, executable QASM circuit with its associated tag."""
102
+
103
+ tag: str
104
+ qasm: str
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class CircuitBundle:
109
+ """
110
+ Represents a bundle of logically related quantum circuits.
111
+
112
+ A CircuitBundle is typically generated from a single `MetaCircuit` by
113
+ instantiating it with concrete parameters. It may contain multiple
114
+ executable circuits due to measurement grouping or error mitigation
115
+ protocols. Each executable circuit has a QASM representation and a
116
+ unique tag for identification.
117
+ """
118
+
119
+ executables: tuple[ExecutableQASMCircuit, ...]
120
+ """Tuple of executable circuits."""
121
+
122
+ def __str__(self):
123
+ """
124
+ Return a string representation of the circuit bundle.
125
+
126
+ Returns:
127
+ str: String in format "CircuitBundle ({num_executables} executables)".
128
+ """
129
+ return f"CircuitBundle ({len(self.executables)} executables)"
130
+
131
+ @property
132
+ def tags(self) -> list[str]:
133
+ """A list of tags for all executables in the bundle."""
134
+ return [e.tag for e in self.executables]
135
+
136
+ @property
137
+ def qasm_circuits(self) -> list[str]:
138
+ """A list of QASM strings for all executables in the bundle."""
139
+ return [e.qasm for e in self.executables]
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class MetaCircuit:
144
+ """
145
+ A parameterized quantum circuit template for batch circuit generation.
146
+
147
+ MetaCircuit represents a symbolic quantum circuit that can be instantiated
148
+ multiple times with different parameter values. It handles circuit compilation,
149
+ observable grouping, and measurement decomposition for efficient execution.
150
+ """
151
+
152
+ source_circuit: qml.tape.QuantumScript
153
+ """The PennyLane quantum circuit with symbolic parameters."""
154
+ symbols: npt.NDArray[np.object_]
155
+ """Array of sympy symbols used as circuit parameters."""
156
+ grouping_strategy: Literal["wires", "default", "qwc", "_backend_expval"] | None = (
157
+ None
158
+ )
159
+ """Strategy for grouping commuting observables."""
160
+ qem_protocol: QEMProtocol | None = None
161
+ """Quantum error mitigation protocol to apply."""
162
+ precision: int = 8
163
+ """Number of decimal places for parameter values in QASM conversion."""
164
+
165
+ # --- Compiled artifacts ---
166
+ _compiled_circuit_bodies: tuple[str, ...] = field(init=False)
167
+ _measurements: tuple[str, ...] = field(init=False)
168
+ measurement_groups: tuple[tuple[qml.operation.Operator, ...], ...] = field(
169
+ init=False
170
+ )
171
+ postprocessing_fn: Callable = field(init=False)
172
+
173
+ def __post_init__(self):
174
+ """
175
+ Compiles the circuit template after initialization.
176
+
177
+ This method performs several steps:
178
+ 1. Decomposes the source circuit's measurement into single-term observables.
179
+ 2. Groups commuting observables according to the specified strategy.
180
+ 3. Generates a post-processing function to correctly combine measurement results.
181
+ 4. Compiles the circuit body and measurement instructions into QASM strings.
182
+ """
183
+ # Validate that the circuit has exactly one valid observable measurement.
184
+ if len(self.source_circuit.measurements) != 1:
185
+ raise ValueError(
186
+ f"MetaCircuit requires a circuit with exactly one measurement, "
187
+ f"but {len(self.source_circuit.measurements)} were found."
188
+ )
189
+
190
+ measurement = self.source_circuit.measurements[0]
191
+ # If the measurement is not an expectation value, we assume it is for sampling
192
+ # and does not require special post-processing.
193
+ if not hasattr(measurement, "obs") or measurement.obs is None:
194
+ postprocessing_fn = lambda x: x
195
+ measurement_groups = ((),)
196
+ (
197
+ compiled_circuit_bodies,
198
+ measurements,
199
+ ) = to_openqasm(
200
+ self.source_circuit,
201
+ measurement_groups=measurement_groups,
202
+ return_measurements_separately=True,
203
+ symbols=self.symbols,
204
+ qem_protocol=self.qem_protocol,
205
+ precision=self.precision,
206
+ )
207
+ # Use object.__setattr__ because the class is frozen
208
+ object.__setattr__(self, "postprocessing_fn", postprocessing_fn)
209
+ object.__setattr__(self, "measurement_groups", measurement_groups)
210
+ object.__setattr__(
211
+ self, "_compiled_circuit_bodies", tuple(compiled_circuit_bodies)
212
+ )
213
+ object.__setattr__(self, "_measurements", tuple(measurements))
214
+
215
+ return
216
+
217
+ # Step 1: Use split_to_single_terms to get a flat list of measurement
218
+ # processes. We no longer need its post-processing function.
219
+ measurements_only_tape = qml.tape.QuantumScript(
220
+ measurements=self.source_circuit.measurements
221
+ )
222
+ s_tapes, _ = TRANSFORM_PROGRAM((measurements_only_tape,))
223
+ single_term_mps = s_tapes[0].measurements
224
+
225
+ # Extract the coefficients, which we will now use in our own post-processing.
226
+ obs = self.source_circuit.measurements[0].obs
227
+ if isinstance(obs, (qml.Hamiltonian, qml.ops.Sum)):
228
+ coeffs, _ = obs.terms()
229
+ else:
230
+ # For single observables, the coefficient is implicitly 1.0
231
+ coeffs = [1.0]
232
+
233
+ # Step 2: Manually group the flat list of measurements based on the strategy.
234
+ if self.grouping_strategy in ("qwc", "default"):
235
+ obs_list = [m.obs for m in single_term_mps]
236
+ # This computes the grouping indices for the flat list of observables
237
+ partition_indices = qml.pauli.compute_partition_indices(obs_list)
238
+ measurement_groups = tuple(
239
+ tuple(single_term_mps[i].obs for i in group)
240
+ for group in partition_indices
241
+ )
242
+ elif self.grouping_strategy == "wires":
243
+ partition_indices, grouped_mps = _wire_grouping(single_term_mps)
244
+ measurement_groups = tuple(
245
+ tuple(m.obs for m in group) for group in grouped_mps
246
+ )
247
+ elif self.grouping_strategy is None:
248
+ # Each measurement is its own group
249
+ measurement_groups = tuple(tuple([m.obs]) for m in single_term_mps)
250
+ partition_indices = [[i] for i in range(len(single_term_mps))]
251
+ elif self.grouping_strategy == "_backend_expval":
252
+ measurement_groups = ((),)
253
+ # For backends that compute expectation values directly, no explicit
254
+ # measurement basis rotations (diagonalizing gates) are needed in the QASM.
255
+ # The `to_openqasm` function interprets an empty measurement group `()`
256
+ # as a signal to skip adding these gates.
257
+ # All observables are still tracked in a single group for post-processing.
258
+ partition_indices = [list(range(len(single_term_mps)))]
259
+ else:
260
+ raise ValueError(f"Unknown grouping strategy: {self.grouping_strategy}")
261
+
262
+ # Step 3: Create our own post-processing function that handles the final summation.
263
+ postprocessing_fn = _create_final_postprocessing_fn(
264
+ coeffs, partition_indices, len(single_term_mps)
265
+ )
266
+
267
+ compiled_circuit_bodies, measurements = to_openqasm(
268
+ self.source_circuit,
269
+ measurement_groups=measurement_groups,
270
+ return_measurements_separately=True,
271
+ # TODO: optimize later
272
+ measure_all=True,
273
+ symbols=self.symbols,
274
+ qem_protocol=self.qem_protocol,
275
+ precision=self.precision,
276
+ )
277
+ # Use object.__setattr__ because the class is frozen
278
+ object.__setattr__(self, "postprocessing_fn", postprocessing_fn)
279
+ object.__setattr__(self, "measurement_groups", measurement_groups)
280
+ object.__setattr__(
281
+ self, "_compiled_circuit_bodies", tuple(compiled_circuit_bodies)
282
+ )
283
+ object.__setattr__(self, "_measurements", tuple(measurements))
284
+
285
+ def __getstate__(self):
286
+ """
287
+ Prepare the MetaCircuit for pickling.
288
+
289
+ Serializes the postprocessing function using dill since regular pickle
290
+ cannot handle certain PennyLane function objects.
291
+
292
+ Returns:
293
+ dict: State dictionary with serialized postprocessing function.
294
+ """
295
+ state = self.__dict__.copy()
296
+ state["postprocessing_fn"] = dill.dumps(self.postprocessing_fn)
297
+ return state
298
+
299
+ def __setstate__(self, state):
300
+ """
301
+ Restore the MetaCircuit from a pickled state.
302
+
303
+ Deserializes the postprocessing function that was serialized with dill
304
+ during pickling.
305
+
306
+ Args:
307
+ state (dict): State dictionary from pickling with serialized
308
+ postprocessing function.
309
+ """
310
+ state["postprocessing_fn"] = dill.loads(state["postprocessing_fn"])
311
+
312
+ self.__dict__.update(state)
313
+
314
+ def initialize_circuit_from_params(
315
+ self, param_list, tag_prefix: str = "", precision: int | None = None
316
+ ) -> CircuitBundle:
317
+ """
318
+ Instantiate a concrete CircuitBundle by substituting symbolic parameters with values.
319
+
320
+ Takes a list of parameter values and creates a fully instantiated CircuitBundle
321
+ by replacing all symbolic parameters in the QASM representations with their
322
+ concrete numerical values.
323
+
324
+ Args:
325
+ param_list: Array of numerical parameter values to substitute for symbols.
326
+ Must match the length and order of self.symbols.
327
+ tag_prefix (str, optional): Prefix to prepend to circuit tags for
328
+ identification. Defaults to "".
329
+ precision (int | None, optional): Number of decimal places for parameter values
330
+ in the QASM output. If None, uses the precision set on this MetaCircuit instance.
331
+ Defaults to None.
332
+
333
+ Returns:
334
+ CircuitBundle: A new CircuitBundle instance with parameters substituted and proper
335
+ tags for identification.
336
+
337
+ Note:
338
+ The main circuit's parameters are still in symbol form.
339
+ Not sure if it is necessary for any useful application to parameterize them.
340
+ """
341
+ if precision is None:
342
+ precision = self.precision
343
+ mapping = dict(
344
+ zip(
345
+ map(lambda x: re.escape(str(x)), self.symbols),
346
+ map(lambda x: f"{x:.{precision}f}", param_list),
347
+ )
348
+ )
349
+ pattern = re.compile("|".join(k for k in mapping.keys()))
350
+
351
+ final_qasm_bodies = [
352
+ pattern.sub(lambda match: mapping[match.group(0)], body)
353
+ for body in self._compiled_circuit_bodies
354
+ ]
355
+
356
+ executables = []
357
+ for (i, body_str), (j, meas_str) in product(
358
+ enumerate(final_qasm_bodies), enumerate(self._measurements)
359
+ ):
360
+ qasm_circuit = body_str + meas_str
361
+ tag_parts = [tag_prefix]
362
+ if self.qem_protocol:
363
+ tag_parts.append(f"{self.qem_protocol.name}:{i}")
364
+ tag_parts.append(str(j))
365
+
366
+ tag = "_".join(filter(None, tag_parts))
367
+ executables.append(ExecutableQASMCircuit(tag=tag, qasm=qasm_circuit))
368
+
369
+ return CircuitBundle(executables=tuple(executables))
@@ -5,7 +5,6 @@
5
5
  import re
6
6
  from functools import partial
7
7
  from itertools import product
8
- from typing import Optional
9
8
  from warnings import warn
10
9
 
11
10
  import cirq
@@ -13,10 +12,11 @@ import numpy as np
13
12
  import pennylane as qml
14
13
  from pennylane.tape import QuantumScript
15
14
  from pennylane.wires import Wires
16
- from sympy import Symbol
15
+ from sympy import Expr, Symbol
17
16
 
18
- from divi.exp.cirq import cirq_circuit_from_qasm
19
- from divi.qem import QEMProtocol
17
+ from divi.circuits.qem import QEMProtocol
18
+
19
+ from ._cirq import ExtendedQasmParser as QasmParser
20
20
 
21
21
  OPENQASM_GATES = {
22
22
  "CNOT": "cx",
@@ -46,7 +46,39 @@ OPENQASM_GATES = {
46
46
  }
47
47
 
48
48
 
49
+ def _cirq_circuit_from_qasm(qasm: str) -> cirq.Circuit:
50
+ """Parses an OpenQASM string to `cirq.Circuit`.
51
+
52
+ Args:
53
+ qasm: The OpenQASM string
54
+
55
+ Returns:
56
+ The parsed circuit
57
+ """
58
+
59
+ return QasmParser().parse(qasm).circuit
60
+
61
+
49
62
  def _ops_to_qasm(operations, precision, wires):
63
+ """
64
+ Convert PennyLane operations to OpenQASM instruction strings.
65
+
66
+ Translates a sequence of PennyLane quantum operations into their OpenQASM
67
+ 2.0 equivalent representations. Each operation is mapped to its corresponding
68
+ QASM gate with appropriate parameters and wire labels.
69
+
70
+ Args:
71
+ operations: Sequence of PennyLane operation objects to convert.
72
+ precision (int | None): Number of decimal places for parameter values.
73
+ If None, uses default Python string formatting.
74
+ wires: Wire labels used in the circuit for indexing.
75
+
76
+ Returns:
77
+ str: OpenQASM instruction string with each operation on a new line.
78
+
79
+ Raises:
80
+ ValueError: If an operation is not supported by the QASM serializer.
81
+ """
50
82
  # create the QASM code representing the operations
51
83
  qasm_str = ""
52
84
 
@@ -65,9 +97,17 @@ def _ops_to_qasm(operations, precision, wires):
65
97
  # If the operation takes parameters, construct a string
66
98
  # with parameter values.
67
99
  if precision is not None:
68
- params = (
69
- "(" + ",".join([f"{p:.{precision}}" for p in op.parameters]) + ")"
70
- )
100
+ # Format parameters with precision, but use str() for sympy expressions
101
+ param_strs = []
102
+ for p in op.parameters:
103
+ if isinstance(p, Expr):
104
+ # Sympy expressions (Symbol, Mul, Add, etc.) should be kept as-is
105
+ # (will be replaced later during parameter substitution)
106
+ param_strs.append(str(p))
107
+ else:
108
+ # Numeric parameters can be formatted with precision
109
+ param_strs.append(f"{p:.{precision}}")
110
+ params = "(" + ",".join(param_strs) + ")"
71
111
  else:
72
112
  # use default precision
73
113
  params = "(" + ",".join([str(p) for p in op.parameters]) + ")"
@@ -81,10 +121,10 @@ def to_openqasm(
81
121
  main_qscript,
82
122
  measurement_groups: list[list[qml.measurements.ExpectationMP]],
83
123
  measure_all: bool = True,
84
- precision: Optional[int] = None,
124
+ precision: int | None = None,
85
125
  return_measurements_separately: bool = False,
86
126
  symbols: list[Symbol] = None,
87
- qem_protocol: Optional[QEMProtocol] = None,
127
+ qem_protocol: QEMProtocol | None = None,
88
128
  ) -> list[str] | tuple[str, list[str]]:
89
129
  """
90
130
  Serialize the circuit as an OpenQASM 2.0 program.
@@ -137,7 +177,18 @@ def to_openqasm(
137
177
  return main_qasm_str
138
178
 
139
179
  if qem_protocol:
180
+ # Flatten symbols list to handle both individual symbols and arrays of symbols
181
+ flat_symbols = []
140
182
  for symbol in symbols:
183
+ if isinstance(symbol, np.ndarray):
184
+ # If it's a numpy array of symbols, flatten it
185
+ flat_symbols.extend(symbol.flatten())
186
+ else:
187
+ # Individual symbol
188
+ flat_symbols.append(symbol)
189
+
190
+ # Declare each symbol individually in QASM 3.0
191
+ for symbol in flat_symbols:
141
192
  main_qasm_str += f"input angle[32] {str(symbol)};\n"
142
193
 
143
194
  # create the quantum and classical registers
@@ -167,7 +218,7 @@ def to_openqasm(
167
218
 
168
219
  main_qasm_strs = []
169
220
  if qem_protocol:
170
- for circ in qem_protocol.modify_circuit(cirq_circuit_from_qasm(main_qasm_str)):
221
+ for circ in qem_protocol.modify_circuit(_cirq_circuit_from_qasm(main_qasm_str)):
171
222
  # Convert back to QASM2.0 code, with the symbolic parameters
172
223
  qasm_str = cirq.qasm(circ)
173
224
  # Remove redundant newlines
@@ -186,9 +237,19 @@ def to_openqasm(
186
237
 
187
238
  # Create a copy of the program for every measurement that we have
188
239
  for meas_group in measurement_groups:
240
+ # Ensure all items in measurement group are MeasurementProcess instances
241
+ wrapped_group = [
242
+ m if isinstance(m, qml.measurements.MeasurementProcess) else qml.expval(m)
243
+ for m in meas_group
244
+ ]
245
+
189
246
  curr_diag_qasm_str = (
190
247
  _to_qasm(diag_ops)
191
- if (diag_ops := QuantumScript(measurements=meas_group).diagonalizing_gates)
248
+ if (
249
+ diag_ops := QuantumScript(
250
+ measurements=wrapped_group
251
+ ).diagonalizing_gates
252
+ )
192
253
  else ""
193
254
  )
194
255
 
@@ -197,9 +258,7 @@ def to_openqasm(
197
258
  for wire in range(len(wires)):
198
259
  measure_qasm_str += f"measure q[{wire}] -> c[{wire}];\n"
199
260
  else:
200
- measured_wires = Wires.all_wires(
201
- [m.wires for m in main_qscript.measurements]
202
- )
261
+ measured_wires = Wires.all_wires([m.wires for m in meas_group])
203
262
 
204
263
  for w in measured_wires:
205
264
  wire_indx = main_qscript.wires.index(w)