qilisdk 0.1.4__py3-none-any.whl → 0.1.5__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 (83) hide show
  1. qilisdk/__init__.py +11 -2
  2. qilisdk/__init__.pyi +2 -3
  3. qilisdk/_logging.py +135 -0
  4. qilisdk/_optionals.py +5 -7
  5. qilisdk/analog/__init__.py +3 -18
  6. qilisdk/analog/exceptions.py +2 -4
  7. qilisdk/analog/hamiltonian.py +455 -110
  8. qilisdk/analog/linear_schedule.py +118 -0
  9. qilisdk/analog/schedule.py +272 -79
  10. qilisdk/backends/__init__.py +45 -0
  11. qilisdk/{digital/digital_algorithm.py → backends/__init__.pyi} +3 -5
  12. qilisdk/backends/backend.py +117 -0
  13. qilisdk/{extras/cuda → backends}/cuda_backend.py +152 -159
  14. qilisdk/backends/qutip_backend.py +492 -0
  15. qilisdk/common/__init__.py +48 -2
  16. qilisdk/common/algorithm.py +2 -1
  17. qilisdk/{extras/qaas/qaas_settings.py → common/exceptions.py} +12 -6
  18. qilisdk/common/model.py +1019 -1
  19. qilisdk/common/parameterizable.py +75 -0
  20. qilisdk/common/qtensor.py +666 -0
  21. qilisdk/common/result.py +2 -1
  22. qilisdk/common/variables.py +1931 -0
  23. qilisdk/{extras/cuda/cuda_analog_result.py → cost_functions/__init__.py} +3 -4
  24. qilisdk/cost_functions/cost_function.py +77 -0
  25. qilisdk/cost_functions/model_cost_function.py +145 -0
  26. qilisdk/cost_functions/observable_cost_function.py +109 -0
  27. qilisdk/digital/__init__.py +3 -22
  28. qilisdk/digital/ansatz.py +203 -160
  29. qilisdk/digital/circuit.py +81 -9
  30. qilisdk/digital/exceptions.py +12 -6
  31. qilisdk/digital/gates.py +228 -85
  32. qilisdk/{extras/qaas/qaas_analog_result.py → functionals/__init__.py} +14 -5
  33. qilisdk/functionals/functional.py +39 -0
  34. qilisdk/{extras/cuda/cuda_digital_result.py → functionals/functional_result.py} +3 -4
  35. qilisdk/functionals/sampling.py +81 -0
  36. qilisdk/functionals/sampling_result.py +92 -0
  37. qilisdk/functionals/time_evolution.py +98 -0
  38. qilisdk/functionals/time_evolution_result.py +84 -0
  39. qilisdk/functionals/variational_program.py +80 -0
  40. qilisdk/functionals/variational_program_result.py +69 -0
  41. qilisdk/logging_config.yaml +16 -0
  42. qilisdk/{common/backend.py → optimizers/__init__.py} +2 -1
  43. qilisdk/optimizers/optimizer.py +39 -0
  44. qilisdk/{common → optimizers}/optimizer_result.py +3 -12
  45. qilisdk/{common/optimizer.py → optimizers/scipy_optimizer.py} +10 -28
  46. qilisdk/settings.py +78 -0
  47. qilisdk/{extras → speqtrum}/__init__.py +7 -8
  48. qilisdk/{extras → speqtrum}/__init__.pyi +3 -3
  49. qilisdk/speqtrum/experiments/__init__.py +25 -0
  50. qilisdk/speqtrum/experiments/experiment_functional.py +124 -0
  51. qilisdk/speqtrum/experiments/experiment_result.py +231 -0
  52. qilisdk/{extras/qaas → speqtrum}/keyring.py +8 -4
  53. qilisdk/speqtrum/speqtrum.py +432 -0
  54. qilisdk/speqtrum/speqtrum_models.py +300 -0
  55. qilisdk/utils/__init__.py +0 -14
  56. qilisdk/utils/openqasm2.py +1 -1
  57. qilisdk/utils/serialization.py +1 -1
  58. qilisdk/utils/visualization/PlusJakartaSans-SemiBold.ttf +0 -0
  59. qilisdk/utils/visualization/__init__.py +24 -0
  60. qilisdk/utils/visualization/circuit_renderers.py +781 -0
  61. qilisdk/utils/visualization/schedule_renderers.py +161 -0
  62. qilisdk/utils/visualization/style.py +154 -0
  63. qilisdk/utils/visualization/themes.py +76 -0
  64. qilisdk/yaml.py +126 -0
  65. {qilisdk-0.1.4.dist-info → qilisdk-0.1.5.dist-info}/METADATA +180 -134
  66. qilisdk-0.1.5.dist-info/RECORD +69 -0
  67. qilisdk/analog/algorithms.py +0 -111
  68. qilisdk/analog/analog_backend.py +0 -43
  69. qilisdk/analog/analog_result.py +0 -114
  70. qilisdk/analog/quantum_objects.py +0 -596
  71. qilisdk/digital/digital_backend.py +0 -90
  72. qilisdk/digital/digital_result.py +0 -145
  73. qilisdk/digital/vqe.py +0 -166
  74. qilisdk/extras/cuda/__init__.py +0 -13
  75. qilisdk/extras/qaas/__init__.py +0 -13
  76. qilisdk/extras/qaas/models.py +0 -132
  77. qilisdk/extras/qaas/qaas_backend.py +0 -255
  78. qilisdk/extras/qaas/qaas_digital_result.py +0 -20
  79. qilisdk/extras/qaas/qaas_time_evolution_result.py +0 -20
  80. qilisdk/extras/qaas/qaas_vqe_result.py +0 -20
  81. qilisdk-0.1.4.dist-info/RECORD +0 -51
  82. {qilisdk-0.1.4.dist-info → qilisdk-0.1.5.dist-info}/WHEEL +0 -0
  83. {qilisdk-0.1.4.dist-info → qilisdk-0.1.5.dist-info}/licenses/LICENCE +0 -0
@@ -0,0 +1,781 @@
1
+ # Copyright 2025 Qilimanjaro Quantum Tech
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ from fractions import Fraction
18
+ from typing import TYPE_CHECKING, Final, Iterable
19
+
20
+ import matplotlib.pyplot as plt
21
+ import numpy as np
22
+ from matplotlib.patches import Arc, Circle, FancyArrow, FancyBboxPatch
23
+
24
+ from qilisdk.digital.gates import Controlled, Gate, M, X
25
+ from qilisdk.utils.visualization.style import CircuitStyle
26
+
27
+ if TYPE_CHECKING:
28
+ from matplotlib.axes import Axes
29
+
30
+ from qilisdk.digital import Circuit
31
+
32
+ ###############################################################################
33
+ # Matplotlib implementation
34
+ ###############################################################################
35
+
36
+
37
+ class MatplotlibCircuitRenderer:
38
+ """Render a :class:`~qilisdk.digital.Circuit` using *matplotlib*."""
39
+
40
+ # Z-order groups -------------------------------------------------------
41
+ _Z: Final = {
42
+ "wire": 1,
43
+ "wire_label": 1,
44
+ "gate": 3,
45
+ "node": 3,
46
+ "bridge": 2,
47
+ "connector": 4,
48
+ "gate_label": 4,
49
+ }
50
+
51
+ # ------------------------------------------------------------------
52
+ # Construction
53
+ # ------------------------------------------------------------------
54
+
55
+ def __init__(self, circuit: Circuit, ax: Axes | None = None, *, style: CircuitStyle = CircuitStyle()) -> None:
56
+ self.circuit = circuit
57
+ self.style = style
58
+ self._ax = ax or self._make_axes(style.dpi)
59
+ self._end_measure_qubits: set[int] = set()
60
+ self._wires = circuit.nqubits
61
+ # *layer_widths[w][l]* - width (inches) of layer *l* on wire *w*
62
+ self._layer_widths: dict[int, list[float]] = {w: [] for w in range(circuit.nqubits)}
63
+
64
+ @property
65
+ def axes(self) -> Axes:
66
+ return self._ax
67
+
68
+ def plot(self) -> None:
69
+ """
70
+ Render the circuit on the current axes and show the figure.
71
+
72
+ Traverses the circuit gates once, placing and drawing each element,
73
+ deferring final-column measurements as needed, draws wires and finalizes
74
+ the figure.
75
+ """
76
+ self._generate_layer_gate_mapping()
77
+ self._draw_wire_labels()
78
+
79
+ # ------------------------------------------------------------------
80
+ # 1. compute last-gate index for each qubit ------------------------
81
+ # ------------------------------------------------------------------
82
+ # done elswere
83
+
84
+ # ------------------------------------------------------------------
85
+ # 2. iterate through gates, drawing or deferring measurements -----
86
+ # ------------------------------------------------------------------
87
+ deferred_qubits: set[int] = set()
88
+ self._max_layer_width: list[float] = [self.style.start_pad]
89
+ for layer in self._layer_gate_mapping:
90
+ for _, gate in self._layer_gate_mapping[layer].items():
91
+ if isinstance(gate, M):
92
+ inline_qubits: list[int] = []
93
+ for q in gate.target_qubits:
94
+ if self.last_idx.get(q) == layer:
95
+ deferred_qubits.add(q)
96
+ self._end_measure_qubits.add(q)
97
+ else:
98
+ inline_qubits.append(q)
99
+ if inline_qubits:
100
+ self._draw_inline_measure(inline_qubits, layer=layer)
101
+ continue
102
+
103
+ if isinstance(gate, Controlled):
104
+ self._draw_controlled_gate(gate, layer=layer)
105
+ continue
106
+
107
+ if gate.name == "SWAP":
108
+ self._draw_swap_gate(list(gate.target_qubits or []), layer=layer)
109
+ continue
110
+
111
+ # Targets-only (single or multi-qubit) box
112
+ self._draw_targets_gate(
113
+ label=self._gate_label(gate), targets=list(gate.target_qubits or []), layer=layer
114
+ )
115
+ maxi = max(
116
+ [
117
+ 0.0,
118
+ *(
119
+ self._layer_widths[wire][layer]
120
+ for wire in range(self._wires)
121
+ if len(self._layer_widths[wire]) - 1 >= layer
122
+ ),
123
+ ]
124
+ )
125
+ self._max_layer_width.append(maxi)
126
+
127
+ # ------------------------------------------------------------------
128
+ # 3. draw any deferred (final-column) measurements -----------------
129
+ # ------------------------------------------------------------------
130
+ if deferred_qubits:
131
+ self._draw_concurrent_measures(sorted(deferred_qubits), layer=layer)
132
+
133
+ # ------------------------------------------------------------------
134
+ # final touches -----------------------------------------------------
135
+ # ------------------------------------------------------------------
136
+ self._draw_wires()
137
+ self._finalise_figure()
138
+ # plt.tight_layout()
139
+ plt.show()
140
+
141
+ def save(self, filename: str) -> None: # thin wrapper
142
+ """Save current figure to disk.
143
+
144
+ Args:
145
+ filename: Path to save the figure (e.g., 'circuit.png').
146
+ """
147
+
148
+ self.axes.figure.savefig(filename, bbox_inches="tight") # type: ignore[union-attr]
149
+
150
+ # ------------------------------------------------------------------
151
+ # Low-level drawing helpers (private)
152
+ # ------------------------------------------------------------------
153
+ def _generate_layer_gate_mapping(self) -> None:
154
+ self._layer_gate_mapping: dict[int, dict[int, Gate]] = {}
155
+ gate_maping: dict[int, list[Gate]] = {}
156
+ for qubit in range(self.circuit.nqubits):
157
+ gate_maping[qubit] = []
158
+ for gate in self.circuit.gates:
159
+ qubits = gate.qubits
160
+ if len(qubits) == 1:
161
+ gate_maping[qubits[0]].append(gate)
162
+ elif len(qubits) > 1:
163
+ if self.style.layout == "compact":
164
+ con_qubits = qubits
165
+ elif self.style.layout == "normal":
166
+ con_qubits = tuple(range(min(qubits), max(qubits) + 1))
167
+ for qubit in con_qubits:
168
+ gate_maping[qubit].append(gate)
169
+
170
+ layer: int = 0
171
+ waiting_list: dict[int, Gate] = {}
172
+ completed = [False] * self.circuit.nqubits
173
+ for q, l in gate_maping.items():
174
+ completed[q] = not bool(l)
175
+ ignore_q: list[int] = []
176
+ self.last_idx: dict[int, int] = {}
177
+ while not all(completed):
178
+ if layer not in self._layer_gate_mapping:
179
+ self._layer_gate_mapping[layer] = {}
180
+ for q in range(self.circuit.nqubits):
181
+ if q in ignore_q:
182
+ ignore_q.remove(q)
183
+ continue
184
+ if len(gate_maping[q]) == 0 and not completed[q]:
185
+ completed[q] = True
186
+ self.last_idx[q] = layer - 1
187
+ if q in waiting_list or completed[q]:
188
+ continue
189
+ gate = gate_maping[q][0]
190
+ if gate.nqubits == 1:
191
+ self._layer_gate_mapping[layer][q] = gate
192
+ gate_maping[q].pop(0)
193
+ if gate.nqubits > 1:
194
+ waiting_list[q] = gate
195
+ qubits = gate.qubits
196
+ if self.style.layout == "compact":
197
+ con_qubits = qubits
198
+ elif self.style.layout == "normal":
199
+ con_qubits = tuple(range(min(qubits), max(qubits) + 1))
200
+ if all(key in waiting_list for key in con_qubits) and all(
201
+ waiting_list[qr] == gate for qr in con_qubits
202
+ ):
203
+ self._layer_gate_mapping[layer][q] = gate
204
+ for c_qubit in con_qubits:
205
+ gate_maping[c_qubit].pop(0)
206
+ del waiting_list[c_qubit]
207
+
208
+ if self.style.layout == "compact":
209
+ for m_qubit in range(min(qubits), q):
210
+ if m_qubit not in qubits and m_qubit in self._layer_gate_mapping[layer]:
211
+ ignore_q.append(m_qubit)
212
+ if layer + 1 not in self._layer_gate_mapping:
213
+ self._layer_gate_mapping[layer + 1] = {}
214
+ self._layer_gate_mapping[layer + 1][m_qubit] = self._layer_gate_mapping[layer][
215
+ m_qubit
216
+ ]
217
+ del self._layer_gate_mapping[layer][m_qubit]
218
+ ignore_q += [*(m_qubit for m_qubit in range(q + 1, max(qubits) + 1))]
219
+ if len(gate_maping[q]) == 0 and not completed[q]:
220
+ completed[q] = True
221
+ self.last_idx[q] = layer
222
+ layer += 1
223
+
224
+ def _xpos(self, layer: int) -> float:
225
+ """
226
+ Compute the left x of a given layer.
227
+
228
+ With align_layer=True, this ignores *wires* and aligns across all wires.
229
+
230
+ Args:
231
+ layer: Column index (0 = the initial label pad entry).
232
+
233
+ Returns:
234
+ The x-coordinate (inches) of the left edge of this column.
235
+ """
236
+ return sum(self._max_layer_width[: layer + 1])
237
+
238
+ def _reserve(self, width: float, wires: Iterable[int], layer: int) -> None:
239
+ """
240
+ Reserve width in a column for a set of wires.
241
+
242
+ Args:
243
+ width: Box width (inches), *excluding* left/right gate margins.
244
+ wires: Wires to update.
245
+ layer: Column index to reserve.
246
+ """
247
+ full_width = width + self.style.gate_margin * 2
248
+ for w in wires:
249
+ layers = self._layer_widths[w]
250
+ if len(layers) > layer:
251
+ layers[layer] = max(layers[layer], full_width)
252
+ else:
253
+ for _ in range(len(layers), layer):
254
+ layers.append(0.0)
255
+ layers.append(full_width)
256
+
257
+ def _place(self, wires: Iterable[int], layer: int, *, min_width: float = 0.0) -> float:
258
+ """
259
+ Choose a column and compute its left x for a set of wires.
260
+
261
+ This does *not* draw anything; it optionally reserves a minimal width
262
+ for the selected column to create the column on those wires.
263
+
264
+ Args:
265
+ wires: Wires that must participate in this column.
266
+ min_width: Optional minimal content width to reserve (inches).
267
+
268
+ Returns:
269
+ A tuple ``(x, layer, wires_sorted)``:
270
+ - x: Left x of the column (including left margin).
271
+ - layer: Column index.
272
+ - wires_sorted: Sorted unique wires used for placement.
273
+ """
274
+ wires = [*range(min(wires), max(wires) + 1)]
275
+ x = self._xpos(layer) + self.style.gate_margin
276
+ if min_width:
277
+ self._reserve(min_width, wires, layer)
278
+ return x
279
+
280
+ def _text_width(self, text: str) -> float:
281
+ """
282
+ Measure rendered text width in inches for current DPI/style.
283
+
284
+ Args:
285
+ text: Text to measure (mathtext is supported).
286
+
287
+ Returns:
288
+ The rendered width in inches.
289
+ """
290
+
291
+ t = plt.Text(
292
+ 0,
293
+ 0,
294
+ text,
295
+ fontproperties=self.style.font,
296
+ )
297
+ self.axes.add_artist(t)
298
+ renderer = self.axes.figure.canvas.get_renderer() # type: ignore[attr-defined]
299
+ width = t.get_window_extent(renderer=renderer).width / self.style.dpi
300
+ t.remove()
301
+ return width
302
+
303
+ # Basic primitives ----------------------------------------------------
304
+
305
+ def _draw_targets_gate(
306
+ self,
307
+ *,
308
+ label: str,
309
+ targets: list[int] | None,
310
+ x: float | None = None,
311
+ layer: int | None = None,
312
+ color: str | None = None,
313
+ ) -> tuple[float, int, float]:
314
+ """
315
+ Draw a box gate that touches only *targets* (no controls).
316
+
317
+ If ``x``/``layer`` are not provided, the earliest column that all
318
+ targets can share is used.
319
+
320
+ Args:
321
+ label: Text label to show inside the box.
322
+ targets: Target qubit indices. If None, defaults to all wires.
323
+ x: Optional left x of the column (from a prior `_place`).
324
+ layer: Optional column index (from a prior `_place`).
325
+ color: Optional box fill/edge color.
326
+
327
+ Returns:
328
+ A tuple ``(x, layer, width)`` where:
329
+ - x: Left x used for this column.
330
+ - layer: Column index used.
331
+ - width: Content width (inches) of the box.
332
+ """
333
+ targets = list(targets or range(self._wires))
334
+ t_sorted = sorted(targets)
335
+ a, b = t_sorted[0], t_sorted[-1]
336
+
337
+ # Decide layer/x if not given (no-controls: only target wires matter)
338
+ if layer is None or x is None:
339
+ layer = max(len(self._layer_widths[w]) for w in t_sorted)
340
+ x = self._xpos(layer) + self.style.gate_margin
341
+
342
+ # Measure and reserve the full width at the given x/layer
343
+ width = max(self._text_width(label) + self.style.gate_pad * 2, self.style.min_gate_w)
344
+ self._reserve(width, t_sorted, layer)
345
+
346
+ # Geometry
347
+ y_a = self._ypos(a, n_qubits=self._wires, sep=self.style.wire_sep)
348
+ y_b = self._ypos(b, n_qubits=self._wires, sep=self.style.wire_sep)
349
+ y_bottom = min(y_a, y_b) - self.style.min_gate_h / 2
350
+ height = abs(y_b - y_a) + self.style.min_gate_h
351
+ y_center = (y_a + y_b) / 2.0
352
+
353
+ gate_color = color or self.style.theme.primary
354
+
355
+ # Box
356
+ self.axes.add_patch(
357
+ FancyBboxPatch(
358
+ (x, y_bottom),
359
+ width,
360
+ height,
361
+ boxstyle=self.style.bulge,
362
+ mutation_scale=0.3,
363
+ facecolor=gate_color,
364
+ edgecolor=gate_color,
365
+ zorder=self._Z["gate"],
366
+ )
367
+ )
368
+ # Label
369
+ self.axes.text(
370
+ x + width / 2,
371
+ y_center,
372
+ label,
373
+ ha="center",
374
+ va="center",
375
+ color=self.style.theme.on_primary,
376
+ fontproperties=self.style.font,
377
+ zorder=self._Z["gate_label"],
378
+ )
379
+
380
+ # Visual connectors for multi-targets
381
+ if len(t_sorted) > 1:
382
+ for t in t_sorted:
383
+ y_t = self._ypos(t, n_qubits=self._wires, sep=self.style.wire_sep)
384
+ self.axes.add_patch(
385
+ Circle(
386
+ (x + self.style.connector_r, y_t),
387
+ self.style.connector_r,
388
+ color=self.style.theme.background,
389
+ zorder=self._Z["connector"],
390
+ )
391
+ )
392
+ self.axes.add_patch(
393
+ Circle(
394
+ (x + width - self.style.connector_r, y_t),
395
+ self.style.connector_r,
396
+ color=self.style.theme.background,
397
+ zorder=self._Z["connector"],
398
+ )
399
+ )
400
+
401
+ return x, layer, width
402
+
403
+ def _draw_control_dot(self, wire: int, x: float) -> None:
404
+ """
405
+ Draw a filled control dot at the given wire/x.
406
+
407
+ Args:
408
+ wire: Qubit index.
409
+ x: Column anchor x coordinate.
410
+ """
411
+ y = self._ypos(wire, n_qubits=self._wires, sep=self.style.wire_sep)
412
+ self.axes.add_patch(Circle((x, y), self.style.control_r, color=self.style.theme.accent, zorder=self._Z["node"]))
413
+
414
+ def _draw_plus_sign(self, wire: int, x: float) -> None:
415
+ """
416
+ Draw a target ⊕ marker at the given wire/x.
417
+
418
+ Args:
419
+ wire: Qubit index.
420
+ x: Column anchor x coordinate.
421
+ """
422
+ y = self._ypos(wire, n_qubits=self._wires, sep=self.style.wire_sep)
423
+ self.axes.add_patch(Circle((x, y), self.style.target_r, color=self.style.theme.accent, zorder=self._Z["node"]))
424
+ self.axes.add_line(
425
+ plt.Line2D(
426
+ (x, x),
427
+ (y - self.style.target_r / 2, y + self.style.target_r / 2),
428
+ lw=1.5,
429
+ color=self.style.theme.background,
430
+ zorder=self._Z["gate_label"],
431
+ )
432
+ )
433
+ self.axes.add_line(
434
+ plt.Line2D(
435
+ (x - self.style.target_r / 2, x + self.style.target_r / 2),
436
+ (y, y),
437
+ lw=1.5,
438
+ color=self.style.theme.background,
439
+ zorder=self._Z["gate_label"],
440
+ )
441
+ )
442
+
443
+ def _draw_bridge(self, wire_a: int, wire_b: int, x: float) -> None:
444
+ """
445
+ Draw a vertical bridge line between two wires at x.
446
+
447
+ Args:
448
+ wire_a: First wire.
449
+ wire_b: Second wire.
450
+ x: Column x coordinate where the bridge is drawn.
451
+ """
452
+ y1, y2 = (
453
+ self._ypos(wire_a, n_qubits=self._wires, sep=self.style.wire_sep),
454
+ self._ypos(wire_b, n_qubits=self._wires, sep=self.style.wire_sep),
455
+ )
456
+ self.axes.add_line(plt.Line2D([x, x], [y1, y2], color=self.style.theme.accent, zorder=self._Z["bridge"]))
457
+
458
+ def _draw_swap_mark(self, wire: int, x: float) -> None:
459
+ """
460
+ Draw one X of a SWAP marker on a given wire at x.
461
+
462
+ Args:
463
+ wire: Qubit index.
464
+ x: Column anchor x coordinate.
465
+ """
466
+ y = self._ypos(wire, n_qubits=self._wires, sep=self.style.wire_sep)
467
+ offset = self.style.min_gate_w / 3
468
+ color = self.style.theme.accent
469
+ for xs, ys in (
470
+ ([x + offset, x - offset], [y + self.style.min_gate_h / 4, y - self.style.min_gate_h / 4]),
471
+ ([x - offset, x + offset], [y + self.style.min_gate_h / 4, y - self.style.min_gate_h / 4]),
472
+ ):
473
+ self.axes.add_line(plt.Line2D(xs, ys, color=color, linewidth=2, zorder=self._Z["gate"]))
474
+
475
+ def _draw_swap_gate(
476
+ self,
477
+ targets: list[int],
478
+ layer: int,
479
+ *,
480
+ x: float | None = None,
481
+ ) -> float:
482
+ """
483
+ Draw a SWAP between two target wires.
484
+
485
+ Args:
486
+ targets: Exactly two wires to swap.
487
+ x: Optional left x (from `_place`).
488
+ layer: Optional column index (from `_place`).
489
+
490
+ Returns:
491
+ The anchor x within the column where the swap glyph is centered.
492
+ """
493
+ t_sorted = sorted(targets)
494
+
495
+ if x is None:
496
+ x = self._place(t_sorted, layer, min_width=self.style.target_r * 2)
497
+ else:
498
+ self._reserve(self.style.target_r * 2, t_sorted, layer)
499
+
500
+ x_anchor = x + self.style.gate_pad
501
+
502
+ for t in t_sorted:
503
+ self._draw_swap_mark(t, x_anchor)
504
+ # vertical bridge between the two targets
505
+ self._draw_bridge(t_sorted[0], t_sorted[1], x_anchor)
506
+ return x_anchor
507
+
508
+ def _draw_controlled_gate(self, gate: Controlled, layer: int) -> None:
509
+ """
510
+ Draw a controlled gate (controls + targets).
511
+
512
+ Handles:
513
+ - MCX family as control dots + ⊕ (no box),
514
+ - Controlled-SWAP by reusing swap glyphs,
515
+ - Generic controlled gates as a box over targets with control stems.
516
+
517
+ Args:
518
+ gate: Controlled gate instance.
519
+ """
520
+ targets = list(gate.target_qubits or range(self._wires))
521
+ controls = list(gate.control_qubits or [])
522
+ all_wires = sorted(set(targets + controls))
523
+
524
+ # Place a column shared by all involved wires; reserve minimal node width
525
+ x = self._place(all_wires, layer, min_width=self.style.target_r * 2)
526
+
527
+ # Controlled-X family (CNOT / multi-controlled X): target glyph, not a box
528
+ if gate.is_modified_from(X):
529
+ x_anchor = x + self.style.gate_pad
530
+ for c in controls:
531
+ self._draw_control_dot(c, x_anchor)
532
+ self._draw_bridge(c, targets[0], x_anchor)
533
+ self._draw_plus_sign(targets[0], x_anchor)
534
+ return
535
+
536
+ # Controlled SWAP (Fredkin): reuse the SWAP primitive, then add controls
537
+ if getattr(gate.basic_gate, "name", "") == "SWAP":
538
+ x_anchor = self._draw_swap_gate(targets, x=x, layer=layer)
539
+ for c in controls:
540
+ self._draw_control_dot(c, x_anchor)
541
+ self._draw_bridge(c, targets[0], x_anchor)
542
+ return
543
+
544
+ # Generic controlled gate: draw the target box at this same column,
545
+ # then widen control wires for that layer and add stems to the center.
546
+ label = self._gate_label(gate.basic_gate)
547
+ gate_color = self.style.theme.accent
548
+ x_box, layer_box, width = self._draw_targets_gate(
549
+ label=label, targets=targets, x=x, layer=layer, color=gate_color
550
+ )
551
+
552
+ # Ensure control wires also reserve the same width for this layer
553
+ extra_controls = [c for c in controls if c not in targets]
554
+ if extra_controls:
555
+ self._reserve(width, extra_controls, layer_box)
556
+
557
+ x_center = x_box + width / 2.0
558
+ for c in controls:
559
+ self._draw_control_dot(c, x_center)
560
+ self._draw_bridge(c, targets[0], x_center)
561
+
562
+ # Measurements --------------------------------------------------------
563
+
564
+ def _draw_inline_measure(self, qubits: list[int], layer: int) -> None:
565
+ """
566
+ Draw measurement boxes interleaved with gates (same column).
567
+
568
+ Args:
569
+ qubits: Wires to measure in this column.
570
+ """
571
+ layer = max(len(self._layer_widths[q]) for q in qubits)
572
+ x = self._xpos(layer) + self.style.gate_margin
573
+ self._reserve(self.style.min_gate_w, qubits, layer)
574
+ for q in qubits:
575
+ self._draw_measure_symbol(q, x)
576
+
577
+ def _draw_concurrent_measures(self, qubits: list[int], layer: int) -> None:
578
+ """
579
+ Draw a final column of measurements (one shared column).
580
+
581
+ Args:
582
+ qubits: Wires to measure concurrently.
583
+ """
584
+ layer = max(len(v) for v in self._layer_widths.values())
585
+ x = self._xpos(layer) + self.style.gate_margin
586
+ self._max_layer_width.append(self.style.min_gate_w)
587
+ for q in qubits:
588
+ self._draw_measure_symbol(q, x)
589
+
590
+ def _draw_measure_symbol(self, wire: int, x: float) -> None:
591
+ """
592
+ Draw a measurement glyph at the given wire/x.
593
+
594
+ Args:
595
+ wire: Qubit index.
596
+ x: Shared left x for this measurement column.
597
+ """
598
+ y = self._ypos(wire, n_qubits=self._wires, sep=self.style.wire_sep)
599
+ self.axes.add_patch(
600
+ FancyBboxPatch(
601
+ (x, y - self.style.min_gate_h / 2),
602
+ self.style.min_gate_w,
603
+ self.style.min_gate_h,
604
+ boxstyle=self.style.bulge,
605
+ mutation_scale=0.3,
606
+ facecolor=self.style.theme.background,
607
+ edgecolor=self.style.theme.on_background,
608
+ linewidth=1.25,
609
+ zorder=self._Z["gate"],
610
+ )
611
+ )
612
+ self.axes.add_patch(
613
+ Arc(
614
+ (x + self.style.min_gate_w / 2, y - self.style.min_gate_h / 2),
615
+ self.style.min_gate_w * 1.5,
616
+ self.style.min_gate_h,
617
+ theta1=0,
618
+ theta2=180,
619
+ linewidth=1.25,
620
+ color=self.style.theme.on_background,
621
+ zorder=self._Z["gate_label"],
622
+ )
623
+ )
624
+ self.axes.add_patch(
625
+ FancyArrow(
626
+ x + self.style.min_gate_w / 2,
627
+ y - self.style.min_gate_h / 2,
628
+ dx=self.style.min_gate_w * 0.7,
629
+ dy=self.style.min_gate_h * 0.7,
630
+ length_includes_head=True,
631
+ width=0,
632
+ color=self.style.theme.on_background,
633
+ linewidth=1.25,
634
+ zorder=self._Z["gate_label"],
635
+ )
636
+ )
637
+
638
+ # Final decoration ----------------------------------------------------
639
+
640
+ def _draw_wires(self) -> None:
641
+ """
642
+ Draw horizontal wires up to the last occupied x (plus tail).
643
+
644
+ For wires whose last operation is a measurement, the wire stops at the
645
+ measurement edge with no right-hand tail.
646
+ """
647
+ # how far the drawing for this wire actually goes
648
+ x_end = sum(self._max_layer_width)
649
+ for q in range(self._wires):
650
+ y = self._ypos(q, n_qubits=self._wires, sep=self.style.wire_sep)
651
+ # keep the tail only for wires that KEEP going after their last gate
652
+ self.axes.add_line(
653
+ plt.Line2D([0, x_end], [y, y], lw=1, color=self.style.theme.surface_muted, zorder=self._Z["wire"])
654
+ )
655
+
656
+ def _draw_wire_labels(self) -> None:
657
+ """Draw wire labels to the left of the drawing."""
658
+ labels = self.style.wire_label or [rf"$q_{{{i}}}$" for i in range(self._wires)]
659
+ widths = [self._text_width(lbl) for lbl in labels]
660
+ self._max_label_width = max(widths)
661
+
662
+ for i, label in enumerate(labels):
663
+ y = self._ypos(i, n_qubits=self._wires, sep=self.style.wire_sep)
664
+ self.axes.text(
665
+ -self.style.label_pad,
666
+ y,
667
+ label,
668
+ ha="right",
669
+ va="center",
670
+ fontproperties=self.style.font,
671
+ color=self.style.theme.on_background,
672
+ zorder=self._Z["wire_label"],
673
+ )
674
+
675
+ def _finalise_figure(self) -> None:
676
+ """Finalize axes limits, aspect, background, and title."""
677
+ fig = self.axes.figure
678
+ fig.set_facecolor(self.style.theme.background)
679
+
680
+ total_length = sum(self._max_layer_width)
681
+ x_end = self.style.padding + total_length
682
+
683
+ y_end = self.style.padding + (self._wires - 1) * self.style.wire_sep
684
+
685
+ self.axes.set_xlim(
686
+ -self.style.padding - self._max_label_width - self.style.label_pad,
687
+ x_end,
688
+ )
689
+ self.axes.set_ylim(-self.style.padding, y_end)
690
+
691
+ if self.style.title:
692
+ self.axes.set_title(
693
+ self.style.title,
694
+ pad=10,
695
+ color=self.style.theme.surface_muted,
696
+ fontdict={"fontsize": self.style.fontsize},
697
+ )
698
+
699
+ # In IPython keep figure square so equal aspect ratio does not shrink
700
+ try:
701
+ get_ipython() # type: ignore
702
+ size = max(self.axes.get_xlim()[1] - self.axes.get_xlim()[0], y_end + self.style.padding)
703
+ fig.set_size_inches(size, size, forward=True) # type: ignore[union-attr]
704
+ except NameError:
705
+ fig.set_size_inches( # type: ignore[union-attr]
706
+ self.axes.get_xlim()[1] - self.axes.get_xlim()[0], y_end + self.style.padding, forward=True
707
+ )
708
+
709
+ self.axes.set_aspect("equal", adjustable="box")
710
+ self.axes.axis("off")
711
+
712
+ # ------------------------------------------------------------------
713
+ # Helpers - human-readable gate labels & π-fractions
714
+ # ------------------------------------------------------------------
715
+
716
+ @staticmethod
717
+ def _ypos(index: int, *, n_qubits: int, sep: float) -> float:
718
+ return (n_qubits - 1 - index) * sep
719
+
720
+ @staticmethod
721
+ def _pi_fraction(value: float, /, tol: float = 1e-2) -> str:
722
+ """
723
+ Format a float as a π-fraction (mathtext) when close to a rational.
724
+
725
+ Args:
726
+ value: Angle value (radians).
727
+ tol: Tolerance for accepting the rational approximation.
728
+
729
+ Returns:
730
+ Mathtext string like ``"\\pi/5"`` or fallback decimal.
731
+ """
732
+ coeff = value / np.pi
733
+ frac = Fraction(coeff).limit_denominator(32)
734
+ n, d = frac.numerator, frac.denominator
735
+ if abs(frac - coeff) < tol:
736
+ if n == 0:
737
+ return "0"
738
+ if d == 1:
739
+ return r"\pi" if n == 1 else rf"{n}\pi"
740
+ return rf"\pi/{d}" if n == 1 else rf"{n}\pi/{d}"
741
+ return f"{value:.2f}"
742
+
743
+ @staticmethod
744
+ def _with_superscript_dagger(label: str) -> str:
745
+ # Convert trailing dagger to math superscript, e.g. "RX†" -> r"$\mathrm{RX}^{\dagger}$"
746
+ if label.endswith("†"):
747
+ base = label[:-1]
748
+ return rf"$\mathrm{{{base}}}^{{\dagger}}$"
749
+ return label
750
+
751
+ @staticmethod
752
+ def _gate_label(gate: Gate) -> str:
753
+ """Build a display label for a (possibly parameterized) gate.
754
+
755
+ Args:
756
+ gate: Gate object.
757
+
758
+ Returns:
759
+ Label text. Parameterized gates get ``name ( $args$ )``.
760
+ """
761
+ name = MatplotlibCircuitRenderer._with_superscript_dagger(gate.name)
762
+ if gate.is_parameterized and gate.get_parameter_values():
763
+ parameters = ", ".join(
764
+ MatplotlibCircuitRenderer._pi_fraction(value) for value in gate.get_parameter_values()
765
+ )
766
+ return rf"{name} (${parameters}$)"
767
+ return gate.name
768
+
769
+ @staticmethod
770
+ def _make_axes(dpi: int) -> Axes:
771
+ """
772
+ Create a new figure and axes with the given DPI.
773
+
774
+ Args:
775
+ dpi (int): The DPI of the figure
776
+
777
+ Returns:
778
+ A newly created Matplotlib Axes.
779
+ """
780
+ _, ax = plt.subplots(dpi=dpi, constrained_layout=True)
781
+ return ax