QuantumSheets 0.1.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.
@@ -0,0 +1,34 @@
1
+ Metadata-Version: 2.4
2
+ Name: QuantumSheets
3
+ Version: 0.1.0
4
+ Summary: Musical staff notation for quantum circuits
5
+ Author: Ben Bar
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: qiskit
9
+ Requires-Dist: matplotlib
10
+
11
+ # QuantumSheets
12
+
13
+ Renders Qiskit QuantumCircuits as sheet-music-style diagrams where each qubit is a five-line musical staff.
14
+
15
+ ## Installation
16
+
17
+ You can install the package directly from this repository:
18
+
19
+ ```bash
20
+ pip install .
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ python -m QuantumSheets my_circuit.qasm -o output.png
27
+ python -m QuantumSheets my_circuit.py -o output.png --style clean
28
+ ```
29
+
30
+ Or using the CLI directly:
31
+
32
+ ```bash
33
+ quantumsheets my_circuit.py -o output.png
34
+ ```
@@ -0,0 +1,8 @@
1
+ """
2
+ QuantumSheets — Musical staff notation for quantum circuits.
3
+
4
+ Renders Qiskit QuantumCircuits as sheet-music-style diagrams where each
5
+ qubit is a five-line musical staff.
6
+ """
7
+ from .layout import circuit_to_moments, GateEvent
8
+ from .render import draw_circuit, StaffCircuitDrawer
@@ -0,0 +1,3 @@
1
+ """Allow running as: python -m QuantumSheets <file>"""
2
+ from .cli import main
3
+ main()
@@ -0,0 +1,24 @@
1
+ """
2
+ brace.py
3
+ --------
4
+ Draws the left-edge bracket that groups the qubit staves into one
5
+ "system", the way an orchestral/ensemble score groups separate
6
+ instrument staves with a square bracket (a piano uses a curly brace
7
+ for ONE instrument with two staves; separate wires are more honestly
8
+ drawn as a bracketed group of separate staves).
9
+ """
10
+
11
+
12
+ def draw_vertical_brace(ax, x, y_top, y_bottom, depth=0.32, lw=2.4, color="black", zorder=5):
13
+ """
14
+ Draws a '[' shaped bracket spanning from y_top down to y_bottom,
15
+ with its spine at x - depth and small serifs pointing right at
16
+ both ends.
17
+ """
18
+ spine_x = x - depth
19
+ ax.plot([spine_x, spine_x], [y_top, y_bottom], color=color, lw=lw,
20
+ solid_capstyle="round", zorder=zorder)
21
+ ax.plot([spine_x, spine_x + depth], [y_top, y_top], color=color, lw=lw,
22
+ solid_capstyle="round", zorder=zorder)
23
+ ax.plot([spine_x, spine_x + depth], [y_bottom, y_bottom], color=color, lw=lw,
24
+ solid_capstyle="round", zorder=zorder)
@@ -0,0 +1,76 @@
1
+ """
2
+ cli.py
3
+ ------
4
+ Command-line interface for QuantumSheets. Run on a .qasm or .py file
5
+ that defines a QuantumCircuit to produce a musical-staff diagram.
6
+
7
+ Usage
8
+ -----
9
+ python -m QuantumSheets my_circuit.qasm -o output.png
10
+ python -m QuantumSheets my_circuit.py -o output.png --style clean
11
+ """
12
+ import argparse
13
+ import sys
14
+ import os
15
+
16
+
17
+ def _load_circuit(path):
18
+ """Load a QuantumCircuit from a .qasm or .py file."""
19
+ ext = os.path.splitext(path)[1].lower()
20
+
21
+ if ext in (".qasm",):
22
+ from qiskit import QuantumCircuit
23
+ return QuantumCircuit.from_qasm_file(path)
24
+
25
+ if ext == ".py":
26
+ # Execute the .py file; expect it to leave a 'qc' variable in its namespace.
27
+ ns = {}
28
+ with open(path) as f:
29
+ exec(compile(f.read(), path, "exec"), ns)
30
+ for name in ("qc", "circuit", "circ"):
31
+ if name in ns:
32
+ return ns[name]
33
+ raise RuntimeError(
34
+ f"Could not find a QuantumCircuit variable named 'qc', 'circuit', or 'circ' in {path}"
35
+ )
36
+
37
+ raise ValueError(f"Unsupported file extension: {ext!r} (expected .qasm or .py)")
38
+
39
+
40
+ def main(argv=None):
41
+ parser = argparse.ArgumentParser(
42
+ prog="quantumsheets",
43
+ description="Render a quantum circuit as a musical-staff diagram.",
44
+ )
45
+ parser.add_argument("input", help="Path to a .qasm or .py file defining a QuantumCircuit")
46
+ parser.add_argument("-o", "--output", default=None,
47
+ help="Output filename (png/svg/pdf). Default: show in window.")
48
+ parser.add_argument("--style", choices=("ink", "clean"), default="ink",
49
+ help="Visual style (default: ink)")
50
+ parser.add_argument("--dpi", type=int, default=200, help="Output resolution")
51
+ parser.add_argument("--title", default=None, help="Optional title above the score")
52
+ parser.add_argument("--gates-per-measure", type=int, default=3,
53
+ help="How many gate columns between barlines")
54
+
55
+ args = parser.parse_args(argv)
56
+
57
+ qc = _load_circuit(args.input)
58
+
59
+ from .render import draw_circuit
60
+ fig = draw_circuit(
61
+ qc,
62
+ filename=args.output,
63
+ style=args.style,
64
+ dpi=args.dpi,
65
+ title=args.title,
66
+ gates_per_measure=args.gates_per_measure,
67
+ )
68
+ if args.output:
69
+ print(f"Saved to {args.output}")
70
+ else:
71
+ import matplotlib.pyplot as plt
72
+ plt.show()
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
@@ -0,0 +1,126 @@
1
+ """
2
+ layout.py
3
+ ---------
4
+ Turns a qiskit QuantumCircuit into a simple, drawing-agnostic list of
5
+ "moments" (columns). Each moment holds the operations that happen at
6
+ that horizontal position, exactly the way a textbook circuit diagram
7
+ (and our staff-notation diagram) lays gates out: every wire only ever
8
+ has ONE gate per column, and a gate's column is the first free column
9
+ after all of its wires' previous gates.
10
+
11
+ This file has no matplotlib / drawing code in it on purpose, so it can
12
+ be unit-tested or reused by a different renderer later.
13
+ """
14
+ from dataclasses import dataclass, field
15
+ from typing import List, Optional
16
+
17
+
18
+ @dataclass
19
+ class GateEvent:
20
+ name: str # qiskit instruction name, e.g. "h", "cx", "measure"
21
+ label: str # human readable label to print, e.g. "H", "CNOT", "RZ(1.57)"
22
+ qubits: List[int] # ALL qubit rows touched (controls + targets), in circuit order
23
+ controls: List[int] # subset of qubits that act as controls (empty for non-controlled gates)
24
+ targets: List[int] # subset of qubits that are "acted upon" (targets)
25
+ clbits: List[int] = field(default_factory=list)
26
+ kind: str = "generic" # "single" | "control" | "swap" | "measure" | "barrier" | "generic"
27
+ column: int = 0
28
+
29
+
30
+ # qiskit instruction name -> pretty label
31
+ _PRETTY = {
32
+ "h": "H", "x": "X", "y": "Y", "z": "Z", "s": "S", "sdg": "S†",
33
+ "t": "T", "tdg": "T†", "sx": "√X", "sxdg": "√X†", "id": "I",
34
+ "cx": "CNOT", "cy": "CY", "cz": "CZ", "ch": "CH", "swap": "SWAP",
35
+ "ccx": "TOFFOLI", "cswap": "CSWAP", "measure": "MEAS", "barrier": "BARRIER",
36
+ "reset": "RESET",
37
+ }
38
+
39
+ _PARAM_GATES = {"rx", "ry", "rz", "p", "u", "u1", "u2", "u3", "crx", "cry", "crz", "cp", "cu"}
40
+
41
+
42
+ def _fmt_params(params):
43
+ out = []
44
+ for p in params:
45
+ try:
46
+ out.append(f"{float(p):.2f}")
47
+ except (TypeError, ValueError):
48
+ out.append(str(p))
49
+ return ",".join(out)
50
+
51
+
52
+ def _pretty_label(name, params):
53
+ base = _PRETTY.get(name, name.upper())
54
+ if name in _PARAM_GATES and params:
55
+ base = f"{base}({_fmt_params(params)})"
56
+ return base
57
+
58
+
59
+ def circuit_to_moments(qc) -> List[List[GateEvent]]:
60
+ """
61
+ Greedily packs qc.data into moments (columns), one gate per wire per
62
+ column, mirroring how standard circuit diagrams (and qiskit's own
63
+ drawer) lay things out. Returns a list of moments; each moment is a
64
+ list of GateEvent objects sharing that column.
65
+ """
66
+ n_qubits = qc.num_qubits
67
+ next_free_col = [0] * n_qubits # next available column per qubit wire
68
+ events: List[GateEvent] = []
69
+
70
+ for instr in qc.data:
71
+ op = instr.operation
72
+ name = op.name
73
+ if name in ("delay",):
74
+ # timing-only instruction, irrelevant to the diagram
75
+ continue
76
+
77
+ q_idx = [qc.find_bit(q).index for q in instr.qubits]
78
+ c_idx = [qc.find_bit(c).index for c in instr.clbits] if instr.clbits else []
79
+
80
+ if not q_idx:
81
+ continue
82
+
83
+ col = max(next_free_col[q] for q in q_idx)
84
+
85
+ if name == "barrier":
86
+ kind = "barrier"
87
+ controls, targets = [], q_idx
88
+ elif name == "measure":
89
+ kind = "measure"
90
+ controls, targets = [], q_idx
91
+ elif name == "swap":
92
+ kind = "swap"
93
+ controls, targets = [], q_idx
94
+ elif len(q_idx) >= 2 and name.startswith("c") and name not in ("cswap",):
95
+ # cx, cy, cz, ch, crx, cry, crz, cp, cu, ccx (toffoli), mcx...
96
+ kind = "control"
97
+ n_ctrl = len(q_idx) - 1
98
+ controls, targets = q_idx[:n_ctrl], q_idx[n_ctrl:]
99
+ elif name == "cswap":
100
+ kind = "control" # controlled-swap: draw control + swap pair as generic control group
101
+ controls, targets = [q_idx[0]], q_idx[1:]
102
+ elif len(q_idx) == 1:
103
+ kind = "single"
104
+ controls, targets = [], q_idx
105
+ else:
106
+ kind = "generic"
107
+ controls, targets = [], q_idx
108
+
109
+ label = _pretty_label(name, getattr(op, "params", []))
110
+ ev = GateEvent(
111
+ name=name, label=label, qubits=q_idx, controls=controls,
112
+ targets=targets, clbits=c_idx, kind=kind, column=col,
113
+ )
114
+ events.append(ev)
115
+
116
+ for q in q_idx:
117
+ next_free_col[q] = col + 1
118
+
119
+ if not events:
120
+ return []
121
+
122
+ n_cols = max(e.column for e in events) + 1
123
+ moments: List[List[GateEvent]] = [[] for _ in range(n_cols)]
124
+ for e in events:
125
+ moments[e.column].append(e)
126
+ return moments
@@ -0,0 +1,384 @@
1
+ """
2
+ render.py
3
+ ---------
4
+ Draws a qiskit QuantumCircuit as a "musical staff" diagram: one 5-line
5
+ staff per qubit, a real treble-clef glyph, filled/hollow noteheads with
6
+ stems for gates, an X-notehead for measurements, a proper circle-plus
7
+ target for CNOT, crossing lines for SWAP, and a text label under (or
8
+ beside) every symbol naming exactly which gate it is -- so it looks like
9
+ sheet music but reads like a circuit diagram.
10
+ """
11
+ import os
12
+ import numpy as np
13
+ import matplotlib
14
+ matplotlib.use("Agg")
15
+ import matplotlib.pyplot as plt
16
+ import matplotlib.font_manager as fm
17
+ from matplotlib.patches import Circle, Ellipse, FancyBboxPatch
18
+ from matplotlib.path import Path
19
+ import matplotlib.patches as mpatches
20
+
21
+ from .layout import circuit_to_moments, GateEvent
22
+ from .brace import draw_vertical_brace
23
+
24
+ _HERE = os.path.dirname(os.path.abspath(__file__))
25
+ _FONT_PATH = os.path.join(_HERE, "Euterpe.ttf")
26
+
27
+ # Candidate music-symbol fonts in preference order
28
+ _FALLBACK_FONTS = [
29
+ "/System/Library/Fonts/Apple Symbols.ttf", # macOS
30
+ "/usr/share/fonts/truetype/noto/NotoMusic-Regular.ttf", # Linux (Noto)
31
+ ]
32
+
33
+ # Musical Symbols block -- see glyph catalogue.
34
+ GLYPH = {
35
+ "gclef": "\U0001D11E",
36
+ "barline": "\U0001D100",
37
+ "final_barline": "\U0001D102",
38
+ "notehead_black": "\U0001D158",
39
+ "notehead_white": "\U0001D157",
40
+ "x_notehead": "\U0001D143",
41
+ }
42
+
43
+ STYLES = {
44
+ "ink": dict(bg="#efe4c9", ink="#1f4d36", accent="#c99a2e", paper_edge="#d8c79f"),
45
+ "clean": dict(bg="#ffffff", ink="#1a1a1a", accent="#b8860b", paper_edge="#ffffff"),
46
+ }
47
+
48
+ # ── Geometry ──────────────────────────────────────────────────────────
49
+ # All units are "staff units": 1 unit = distance between two staff lines
50
+ LINE_SPACING = 0.55 # ← tighter lines, like real sheet music
51
+ N_LINES = 5
52
+ STAFF_HEIGHT = (N_LINES - 1) * LINE_SPACING
53
+ STAFF_GAP = 1.8 # ← closer staves (was 3.4)
54
+ DX = 2.0 # horizontal step per moment column
55
+ MEASURE_GAP = 0.8
56
+ X_START = 4.0
57
+ LEFT_LABEL_X = 1.5
58
+ CLEF_X = 2.35
59
+ BRACE_X = 0.45
60
+
61
+ # Note stem length (in staff units)
62
+ STEM_LEN = 2.0
63
+
64
+ # deterministic small pitch variety for single-qubit gates (purely decorative;
65
+ # which STAFF a note sits on -- not its height -- is what carries meaning)
66
+ _PITCH_OFFSET = {
67
+ "h": 0.3, "x": -0.3, "y": 0.6, "z": -0.6, "s": 0.6, "sdg": -0.6,
68
+ "t": 0.0, "tdg": 0.0, "sx": 0.3, "sxdg": -0.3, "id": 0.0, "reset": -0.6,
69
+ }
70
+
71
+ _MUSIC_FONT_PROP = None # cached after first call
72
+
73
+
74
+ def _music_font():
75
+ """Return a FontProperties that can render music-symbol glyphs, or None."""
76
+ global _MUSIC_FONT_PROP
77
+ if _MUSIC_FONT_PROP is not None:
78
+ return _MUSIC_FONT_PROP if _MUSIC_FONT_PROP != "NONE" else None
79
+
80
+ candidates = [_FONT_PATH] + _FALLBACK_FONTS
81
+ for path in candidates:
82
+ if os.path.isfile(path):
83
+ try:
84
+ fm.fontManager.addfont(path)
85
+ _MUSIC_FONT_PROP = fm.FontProperties(fname=path)
86
+ return _MUSIC_FONT_PROP
87
+ except Exception:
88
+ continue
89
+ # No music font found — callers will draw a text fallback.
90
+ _MUSIC_FONT_PROP = "NONE"
91
+ return None
92
+
93
+
94
+ def _pitch_offset(ev: GateEvent) -> float:
95
+ if ev.name in _PITCH_OFFSET:
96
+ return _PITCH_OFFSET[ev.name]
97
+ return 0.4 if (hash(ev.name) % 2 == 0) else -0.4
98
+
99
+
100
+ class StaffCircuitDrawer:
101
+ def __init__(self, qc, style="clean", gates_per_measure=3, title=None):
102
+ self.qc = qc
103
+ self.n_qubits = qc.num_qubits
104
+ self.style = STYLES[style]
105
+ self.gates_per_measure = max(1, gates_per_measure)
106
+ self.title = title
107
+ self.music_prop = _music_font()
108
+ self.moments = circuit_to_moments(qc)
109
+ self.n_cols = len(self.moments)
110
+
111
+ self.xs = self._compute_column_xs()
112
+ self.end_x = (self.xs[-1] if self.xs else X_START) + DX * 0.6 + 0.6
113
+
114
+ # ---------- geometry helpers ----------
115
+ def _compute_column_xs(self):
116
+ xs = []
117
+ x = X_START
118
+ for c in range(self.n_cols):
119
+ if c > 0 and c % self.gates_per_measure == 0:
120
+ x += MEASURE_GAP
121
+ xs.append(x)
122
+ x += DX
123
+ return xs
124
+
125
+ def _staff_top_y(self, qubit: int) -> float:
126
+ # qubit 0 = top staff
127
+ return -qubit * (STAFF_HEIGHT + STAFF_GAP)
128
+
129
+ def _mid_y(self, qubit: int) -> float:
130
+ return self._staff_top_y(qubit) - (N_LINES // 2) * LINE_SPACING
131
+
132
+ def _y_of(self, qubit: int, offset: float = 0.0) -> float:
133
+ return self._mid_y(qubit) + offset * LINE_SPACING
134
+
135
+ def _measure_barline_xs(self):
136
+ bl = []
137
+ for c in range(self.gates_per_measure, self.n_cols, self.gates_per_measure):
138
+ bl.append((self.xs[c - 1] + self.xs[c]) / 2.0)
139
+ return bl
140
+
141
+ # ---------- drawing primitives ----------
142
+ def _text(self, ax, x, y, s, size=10.5, weight="normal", style="normal",
143
+ color=None, ha="center", va="center", zorder=10):
144
+ ax.text(x, y, s, fontsize=size, fontweight=weight, fontstyle=style,
145
+ color=color or self.style["ink"], ha=ha, va=va,
146
+ family="DejaVu Serif", zorder=zorder)
147
+
148
+ def _music_glyph(self, ax, x, y, key, size=34, color=None, va="center", zorder=8):
149
+ c = color or self.style["ink"]
150
+ if self.music_prop is not None:
151
+ ax.text(x, y, GLYPH[key], fontsize=size, fontproperties=self.music_prop,
152
+ color=c, ha="center", va=va, zorder=zorder)
153
+ elif key == "gclef":
154
+ # Text fallback: draw a stylised "G" clef substitute
155
+ ax.text(x, y, "\U0001D11E", fontsize=size, color=c,
156
+ ha="center", va=va, zorder=zorder,
157
+ family="serif")
158
+
159
+ def _notehead(self, ax, x, y, filled=True, size=None, color=None, stem=True):
160
+ """
161
+ Draw a proper notehead with an optional stem, like a real quarter
162
+ note (filled) or half note (hollow).
163
+ """
164
+ c = color or self.style["ink"]
165
+ # Notehead ellipse — tilted like a real music note
166
+ w = LINE_SPACING * 1.15
167
+ h = LINE_SPACING * 0.80
168
+ face = c if filled else "none"
169
+ lw_head = 1.8 if filled else 1.6
170
+ ax.add_patch(Ellipse((x, y), width=w, height=h, angle=-16,
171
+ facecolor=face, edgecolor=c, lw=lw_head, zorder=9))
172
+ # Stem — straight vertical line going up from the right of the notehead
173
+ if stem:
174
+ stem_x = x + w * 0.42
175
+ stem_bottom = y
176
+ stem_top = y + STEM_LEN * LINE_SPACING
177
+ ax.plot([stem_x, stem_x], [stem_bottom, stem_top],
178
+ color=c, lw=1.6, solid_capstyle="round", zorder=9)
179
+
180
+ def _x_notehead(self, ax, x, y, size=None, color=None, r=None, lw=2.0):
181
+ c = color or self.style["ink"]
182
+ if r is None:
183
+ r = 0.22 * LINE_SPACING * 2
184
+ ax.plot([x - r, x + r], [y - r, y + r], color=c, lw=lw, zorder=9)
185
+ ax.plot([x - r, x + r], [y + r, y - r], color=c, lw=lw, zorder=9)
186
+
187
+ def _target_symbol(self, ax, x, y, r=None, color=None, lw=2.0):
188
+ c = color or self.style["ink"]
189
+ if r is None:
190
+ r = 0.28 * LINE_SPACING * 2
191
+ ax.add_patch(Circle((x, y), r, fill=False, edgecolor=c, lw=lw, zorder=9))
192
+ ax.plot([x - r, x + r], [y, y], color=c, lw=lw, zorder=9)
193
+ ax.plot([x, x], [y - r, y + r], color=c, lw=lw, zorder=9)
194
+
195
+ def _control_dot(self, ax, x, y, color=None, r=None):
196
+ c = color or self.style["ink"]
197
+ if r is None:
198
+ r = 0.13 * LINE_SPACING * 2
199
+ ax.add_patch(Circle((x, y), r, fill=True, facecolor=c, edgecolor=c, zorder=9))
200
+
201
+ # ---------- staves ----------
202
+ def _draw_staff_lines(self, ax, qubit):
203
+ top = self._staff_top_y(qubit)
204
+ for j in range(N_LINES):
205
+ y = top - j * LINE_SPACING
206
+ ax.plot([BRACE_X, self.end_x], [y, y], color=self.style["ink"],
207
+ lw=0.8, alpha=0.9, zorder=1)
208
+
209
+ def _draw_clef_and_label(self, ax, qubit):
210
+ mid = self._mid_y(qubit)
211
+ # Larger treble clef — should visually fill the staff
212
+ clef_size = max(44, int(80 * LINE_SPACING))
213
+ self._music_glyph(ax, CLEF_X, mid + 0.10 * LINE_SPACING, "gclef",
214
+ size=clef_size, va="center")
215
+ # Large qubit label styled like a time signature (e.g. "q" over "0")
216
+ self._text(ax, LEFT_LABEL_X, mid + 0.6 * LINE_SPACING,
217
+ "q", size=20, weight="bold", ha="right", va="center")
218
+ self._text(ax, LEFT_LABEL_X, mid - 0.6 * LINE_SPACING,
219
+ f"{qubit}", size=20, weight="bold", ha="right", va="center")
220
+
221
+ def _draw_initial_state(self, ax, qubit):
222
+ x = X_START - 1.05
223
+ y = self._mid_y(qubit)
224
+ self._notehead(ax, x, y, filled=False, color=self.style["accent"], stem=False)
225
+ self._text(ax, x, y - 1.1 * LINE_SPACING, "|0\u27e9", size=8,
226
+ style="italic", color=self.style["accent"])
227
+
228
+ def _draw_barlines(self, ax, y_top, y_bottom):
229
+ for bx in self._measure_barline_xs():
230
+ ax.plot([bx, bx], [y_top, y_bottom], color=self.style["ink"], lw=1.2, zorder=2)
231
+ # Initial barline after clef/init area
232
+ init_bar_x = (X_START - 1.05 + self.xs[0]) / 2.0 - 0.10 if self.xs else X_START
233
+ ax.plot([init_bar_x, init_bar_x], [y_top, y_bottom],
234
+ color=self.style["ink"], lw=1.0, zorder=2)
235
+ # Final double/thick barline
236
+ fx = self.end_x - 0.30
237
+ ax.plot([fx - 0.12, fx - 0.12], [y_top, y_bottom],
238
+ color=self.style["ink"], lw=1.0, zorder=2)
239
+ ax.plot([fx, fx], [y_top, y_bottom],
240
+ color=self.style["ink"], lw=2.8, zorder=2)
241
+
242
+ # ---------- gate rendering ----------
243
+ def _draw_event(self, ax, ev: GateEvent):
244
+ x = self.xs[ev.column]
245
+ ink = self.style["ink"]
246
+
247
+ if ev.kind == "barrier":
248
+ y0 = self._y_of(min(ev.qubits), -2.2)
249
+ y1 = self._y_of(max(ev.qubits), 2.2)
250
+ ax.plot([x, x], [y1, y0], color=ink, lw=1.2,
251
+ ls=(0, (4, 3)), alpha=0.55, zorder=6)
252
+ self._text(ax, x, y1 + 0.6 * LINE_SPACING, "barrier",
253
+ size=7, style="italic", color=ink)
254
+ return
255
+
256
+ if ev.kind == "measure":
257
+ q = ev.targets[0]
258
+ y = self._y_of(q, 0.0)
259
+ self._x_notehead(ax, x, y)
260
+ self._text(ax, x, y - 1.2 * LINE_SPACING, "M",
261
+ size=9, weight="bold", style="italic")
262
+ return
263
+
264
+ if ev.kind == "single":
265
+ q = ev.targets[0]
266
+ off = _pitch_offset(ev)
267
+ y = self._y_of(q, off)
268
+ filled = ev.name != "h"
269
+ self._notehead(ax, x, y, filled=filled, stem=True)
270
+ label_y = y - 1.1 * LINE_SPACING if off >= 0 else y + 1.4 * LINE_SPACING
271
+ self._text(ax, x, label_y, ev.label,
272
+ size=8, weight="bold", style="italic")
273
+ return
274
+
275
+ if ev.kind == "swap":
276
+ q0, q1 = ev.targets[0], ev.targets[1]
277
+ y0, y1 = self._y_of(q0, 0.0), self._y_of(q1, 0.0)
278
+ ax.plot([x, x], [y0, y1], color=ink, lw=1.5, zorder=6)
279
+ r_swap = 0.25 * LINE_SPACING * 2
280
+ self._x_notehead(ax, x, y0, r=r_swap, lw=2.0)
281
+ self._x_notehead(ax, x, y1, r=r_swap, lw=2.0)
282
+ mid_y = (y0 + y1) / 2.0
283
+ self._text(ax, x + 0.6, mid_y, "SWAP",
284
+ size=8.5, weight="bold", style="italic", ha="left")
285
+ return
286
+
287
+ if ev.kind == "control":
288
+ all_q = sorted(ev.qubits)
289
+ y_top = self._y_of(all_q[0], 0.0)
290
+ y_bot = self._y_of(all_q[-1], 0.0)
291
+ ax.plot([x, x], [y_top, y_bot], color=ink, lw=1.5, zorder=6)
292
+
293
+ for q in ev.controls:
294
+ self._control_dot(ax, x, self._y_of(q, 0.0))
295
+
296
+ for q in ev.targets:
297
+ yt = self._y_of(q, 0.0)
298
+ if ev.name == "cx":
299
+ self._target_symbol(ax, x, yt)
300
+ elif ev.name == "cz":
301
+ self._control_dot(ax, x, yt)
302
+ else:
303
+ self._notehead(ax, x, yt, filled=True, stem=False)
304
+
305
+ label_y = (y_top + y_bot) / 2.0
306
+ self._text(ax, x + 0.6, label_y, ev.label,
307
+ size=8.5, weight="bold", style="italic", ha="left")
308
+ return
309
+
310
+ # generic fallback: treat like a "control" block with plain noteheads
311
+ all_q = sorted(ev.qubits)
312
+ y_top = self._y_of(all_q[0], 0.0)
313
+ y_bot = self._y_of(all_q[-1], 0.0)
314
+ if len(all_q) > 1:
315
+ ax.plot([x, x], [y_top, y_bot], color=ink, lw=1.5, zorder=6)
316
+ for q in all_q:
317
+ self._notehead(ax, x, self._y_of(q, 0.0), filled=True, stem=False)
318
+ self._text(ax, x + 0.6, (y_top + y_bot) / 2.0, ev.label,
319
+ size=8.5, weight="bold", style="italic", ha="left")
320
+
321
+ # ---------- public entry point ----------
322
+ def draw(self, figsize=None, dpi=170):
323
+ n = self.n_qubits
324
+ top_y = self._staff_top_y(0) + 1.6
325
+ bottom_y = self._staff_top_y(n - 1) - STAFF_HEIGHT - 1.0
326
+ width = self.end_x + 1.0
327
+ height = top_y - bottom_y
328
+
329
+ if figsize is None:
330
+ figsize = (max(10.0, width * 0.72), max(3.0, height * 0.72))
331
+
332
+ fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
333
+ fig.patch.set_facecolor(self.style["bg"])
334
+ ax.set_facecolor(self.style["bg"])
335
+
336
+ for q in range(n):
337
+ self._draw_staff_lines(ax, q)
338
+ self._draw_clef_and_label(ax, q)
339
+ self._draw_initial_state(ax, q)
340
+
341
+ draw_vertical_brace(ax, BRACE_X, top_y - 0.9, bottom_y + 0.9,
342
+ color=self.style["ink"], lw=2.0)
343
+
344
+ self._draw_barlines(ax, top_y - 0.9, bottom_y + 0.9)
345
+
346
+ for moment in self.moments:
347
+ for ev in moment:
348
+ self._draw_event(ax, ev)
349
+
350
+ if self.title:
351
+ self._text(ax, width / 2.0, top_y + 0.6, self.title, size=15,
352
+ weight="bold", ha="center", va="bottom")
353
+
354
+ ax.set_xlim(-0.3, width)
355
+ ax.set_ylim(bottom_y, top_y + (1.2 if self.title else 0.3))
356
+ ax.set_aspect("equal")
357
+ ax.axis("off")
358
+ fig.tight_layout(pad=0.5)
359
+ return fig
360
+
361
+
362
+ def draw_circuit(qc, filename=None, style="clean", gates_per_measure=3,
363
+ title=None, dpi=200):
364
+ """
365
+ Renders a qiskit QuantumCircuit as a musical-staff circuit diagram.
366
+
367
+ Parameters
368
+ ----------
369
+ qc : qiskit.QuantumCircuit
370
+ filename : str, optional -- if given, saves the figure (png/svg/pdf by extension)
371
+ style : "ink" (aged paper + green ink) or
372
+ "clean" (white background, black ink, good for printing) ← default
373
+ gates_per_measure : how many circuit "moments" to group between barlines
374
+ title : optional title text drawn above the score
375
+
376
+ Returns
377
+ -------
378
+ matplotlib.figure.Figure
379
+ """
380
+ drawer = StaffCircuitDrawer(qc, style=style, gates_per_measure=gates_per_measure, title=title)
381
+ fig = drawer.draw(dpi=dpi)
382
+ if filename:
383
+ fig.savefig(filename, facecolor=fig.get_facecolor(), bbox_inches="tight")
384
+ return fig
@@ -0,0 +1,34 @@
1
+ Metadata-Version: 2.4
2
+ Name: QuantumSheets
3
+ Version: 0.1.0
4
+ Summary: Musical staff notation for quantum circuits
5
+ Author: Ben Bar
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: qiskit
9
+ Requires-Dist: matplotlib
10
+
11
+ # QuantumSheets
12
+
13
+ Renders Qiskit QuantumCircuits as sheet-music-style diagrams where each qubit is a five-line musical staff.
14
+
15
+ ## Installation
16
+
17
+ You can install the package directly from this repository:
18
+
19
+ ```bash
20
+ pip install .
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ python -m QuantumSheets my_circuit.qasm -o output.png
27
+ python -m QuantumSheets my_circuit.py -o output.png --style clean
28
+ ```
29
+
30
+ Or using the CLI directly:
31
+
32
+ ```bash
33
+ quantumsheets my_circuit.py -o output.png
34
+ ```
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ QuantumSheets/__init__.py
4
+ QuantumSheets/__main__.py
5
+ QuantumSheets/brace.py
6
+ QuantumSheets/cli.py
7
+ QuantumSheets/layout.py
8
+ QuantumSheets/render.py
9
+ QuantumSheets.egg-info/PKG-INFO
10
+ QuantumSheets.egg-info/SOURCES.txt
11
+ QuantumSheets.egg-info/dependency_links.txt
12
+ QuantumSheets.egg-info/entry_points.txt
13
+ QuantumSheets.egg-info/requires.txt
14
+ QuantumSheets.egg-info/top_level.txt
15
+ tests/test_quantumsheets.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ quantumsheets = QuantumSheets.cli:main
@@ -0,0 +1,2 @@
1
+ qiskit
2
+ matplotlib
@@ -0,0 +1 @@
1
+ QuantumSheets
@@ -0,0 +1,24 @@
1
+ # QuantumSheets
2
+
3
+ Renders Qiskit QuantumCircuits as sheet-music-style diagrams where each qubit is a five-line musical staff.
4
+
5
+ ## Installation
6
+
7
+ You can install the package directly from this repository:
8
+
9
+ ```bash
10
+ pip install .
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ python -m QuantumSheets my_circuit.qasm -o output.png
17
+ python -m QuantumSheets my_circuit.py -o output.png --style clean
18
+ ```
19
+
20
+ Or using the CLI directly:
21
+
22
+ ```bash
23
+ quantumsheets my_circuit.py -o output.png
24
+ ```
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "QuantumSheets"
7
+ version = "0.1.0"
8
+ description = "Musical staff notation for quantum circuits"
9
+ authors = [
10
+ { name = "Ben Bar" }
11
+ ]
12
+ dependencies = [
13
+ "qiskit",
14
+ "matplotlib"
15
+ ]
16
+ readme = "README.md"
17
+ requires-python = ">=3.8"
18
+
19
+ [project.scripts]
20
+ quantumsheets = "QuantumSheets.cli:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ test_quantumsheets.py
4
+ --------------------
5
+ Comprehensive test: builds a circuit with every gate type the QuantumSheets
6
+ package is designed to handle, renders it, and saves a PNG.
7
+ """
8
+ import sys, os
9
+
10
+ # Allow importing QuantumSheets as a package from the parent directory
11
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+
13
+ from qiskit import QuantumCircuit
14
+ from QuantumSheets.render import draw_circuit
15
+
16
+ # ---------- build a circuit with every gate type ----------
17
+ qc = QuantumCircuit(4, 2)
18
+
19
+ # Single-qubit gates
20
+ qc.h(0)
21
+ qc.x(1)
22
+ qc.z(2)
23
+
24
+ # Parametric gate
25
+ qc.rz(1.5708, 3) # ≈ π/2
26
+
27
+ # Barrier
28
+ qc.barrier()
29
+
30
+ # CNOT (cx) — control on q0, target on q1
31
+ qc.cx(0, 1)
32
+
33
+ # CZ — control on q2, target on q3
34
+ qc.cz(2, 3)
35
+
36
+ # Toffoli — controls on q0,q1, target on q2
37
+ qc.ccx(0, 1, 2)
38
+
39
+ # SWAP — swap q1 and q3
40
+ qc.swap(1, 3)
41
+
42
+ # Another barrier
43
+ qc.barrier()
44
+
45
+ # More single-qubit for variety
46
+ qc.h(2)
47
+ qc.x(0)
48
+
49
+ # Measurements
50
+ qc.measure(0, 0)
51
+ qc.measure(1, 1)
52
+
53
+ print(f"Circuit: {qc.num_qubits} qubits, {qc.num_clbits} classical bits")
54
+ print(f"Operations: {len(qc.data)}")
55
+ print()
56
+ print(qc.draw(output="text"))
57
+ print()
58
+
59
+ # ---------- render ----------
60
+ out_dir = os.path.dirname(os.path.abspath(__file__))
61
+ fname = os.path.join(out_dir, "test_clean.png")
62
+
63
+ fig = draw_circuit(
64
+ qc,
65
+ filename=fname,
66
+ style="clean",
67
+ title="Quantum Circuit — Staff Notation",
68
+ dpi=200,
69
+ gates_per_measure=3,
70
+ )
71
+ import matplotlib.pyplot as plt
72
+ plt.close(fig)
73
+ print(f"✓ Saved {fname}")
74
+ print("\nDone.")