Core Circuit Interface
Overview
This chapter introduces the core data structures and operations of UnitaryLab quantum circuits, and is suitable for users who want to build and manipulate quantum circuits. After reading this chapter, you will be able to:
- Create
Circuitobjects in multiple ways - Add single-qubit gates, controlled gates, and rotation gates (including multi-controlled gates)
- Add measurement operations
- Run simulations and read results
- Use circuit transformation methods (copy, inverse, append, etc.)
- Import and export OpenQASM and Python source code
- Understand UnitaryLab’s qubit ordering convention
Module Index
| Module | Main classes |
|---|---|
unitarylab.core.circuit | CircuitBase, Circuit |
unitarylab.core.register | Register |
unitarylab.core.classical_register | ClassicalRegister |
unitarylab.backend.gatesequence.gatesequence | GateSequenceBase, GateSequence |
unitarylab.backend.gate.gatebase | QuantumGate |
Circuit, Register, and ClassicalRegister can also be imported directly from the top level of the unitarylab package: from unitarylab import Circuit, Register, ClassicalRegister.
1. Creating a Circuit
from unitarylab import Circuit
# Method 1: specify the number of qubits directly (most common)
qc = Circuit(4)
# Method 2: pass an initial statevector (to start the simulation from a known quantum state)
import numpy as np
state = np.array([1, 0, 0, 0], dtype=complex) # corresponds to |00⟩
qc = Circuit(state)
# Method 3: pass Register and ClassicalRegister objects (used when named registers or measurements are needed)
from unitarylab import Register, ClassicalRegister
qr = Register('q', 3)
cr = ClassicalRegister('c', 3)
qc = Circuit(qr, cr)
# Optional: name the circuit (defaults to "Circuit")
qc = Circuit(4, name="my_circuit")Notes:
- Method 1 is suitable for quickly building a circuit, and is recommended when measurements are not needed.
- The statevector passed in Method 2 must have a length that is a power of 2, and must be normalized.
- Method 3 is used when you need to perform measurements and read classical bit results, or when you need multiple named registers.
Circuitinternally maintains aGateSequenceautomatically; each time a gate method is called, aQuantumGateis appended to it (QuantumGateis a frozen dataclass and is immutable once created).
2. Adding Single-Qubit Gates
For all single-qubit gates, the first argument is the target qubit index (0-based). Almost all gate methods support both a single int and a list[int] for the target/control arguments: when a list is passed, the same gate is applied to each qubit in the list, equivalent to calling the method once for each qubit separately.
qc = Circuit(3)
qc.x(0) # Pauli-X (NOT) gate
qc.y(1) # Pauli-Y gate
qc.z(2) # Pauli-Z gate
qc.h(0) # Hadamard gate
qc.s(1) # S gate (π/2 phase)
qc.sdag(1) # S† gate
qc.t(2) # T gate (π/4 phase)
qc.tdag(2) # T† gate
qc.sqrtx(0) # √X gate
qc.sqrtxdag(0) # √X† gate
qc.sqrty(1) # √Y gate
qc.sqrtydag(1) # √Y† gate
qc.i(0) # Identity gate (no-op, can be used as a placeholder)
# target supports a list: apply the same gate to multiple qubits at once
qc2 = Circuit(3)
qc2.h([0, 1, 2]) # equivalent to calling qc2.h(0), qc2.h(1), qc2.h(2) separatelyCommon single-qubit gate quick reference:
| Method | Gate name | Description |
|---|---|---|
x(target) | Pauli-X | Quantum NOT gate; flips to |
y(target) | Pauli-Y | Rotation by π about the Y axis |
z(target) | Pauli-Z | Phase-flips |
h(target) | Hadamard | Creates the superposition |
s(target) / sdag(target) | S / S† | Phase gate, equivalent to and its conjugate |
t(target) / tdag(target) | T / T† | Phase gate, equivalent to and its conjugate |
sqrtx(target) / sqrtxdag(target) | √X / √X† | Applying sqrtx twice in a row is equivalent to x |
sqrty(target) / sqrtydag(target) | √Y / √Y† | Applying sqrty twice in a row is equivalent to y |
i(target) | Identity | Identity gate (no-op) |
3. Adding Rotation Gates
Rotation gates require a rotation angle (in radians), and likewise support a list for target:
import numpy as np
qc = Circuit(2)
# RX(θ): rotate by θ about the X axis
qc.rx(np.pi / 2, 0)
# RY(θ): rotate about the Y axis
qc.ry(np.pi / 4, 1)
# RZ(θ): rotate about the Z axis
qc.rz(np.pi, 0)
# Phase gate P(θ): applies an e^{iθ} phase only to |1⟩
qc.p(np.pi / 2, 1)
# U1(λ) ≡ U(0, 0, λ), U2(φ, λ) ≡ U(0, φ, λ), U3 is the general single-qubit gate
qc.u1(np.pi / 4, 0)
qc.u2(0, np.pi, 1)
qc.u3(np.pi / 2, 0, np.pi, 0)
# Global phase gate (does not take a target; acts on the entire circuit)
qc.gp(np.pi / 8)| Method | Parameters | Description |
|---|---|---|
rx(angle, target) | angle (radians) | Rotation about the X axis |
ry(angle, target) | angle (radians) | Rotation about the Y axis |
rz(angle, target) | angle (radians) | Rotation about the Z axis |
p(angle, target) | angle (radians) | Phase gate |
u1(lmb, target) | λ | U1(λ) ≡ U(0, 0, λ) |
u2(phi, lmb, target) | φ, λ | U2(φ, λ) ≡ U(0, φ, λ) |
u3(theta, phi, lmb, target) | θ, φ, λ | General single-qubit gate U3(θ, φ, λ) |
gp(angle) | angle (radians) | Global phase gate, no target parameter |
4. Adding Controlled Gates
All controlled gate methods accept an optional control_state parameter: None (the default) means all control qubits must be 1 (active-high) to trigger the gate; you can also pass a custom control-qubit pattern (e.g., the string '01' means the gate triggers when the first control qubit is 0 and the second is 1 — see the usage in the control() section for details).
qc = Circuit(3)
# CNOT: control qubit 0, target qubit 1
qc.cx(0, 1)
qc.cnot(0, 1) # alias for cx
# Multi-controlled X (Toffoli): list of control qubits, target qubit
qc.mcx([0, 1], 2)
# Controlled Y / Z / H / S
qc.cy(0, 1)
qc.cz(0, 1)
qc.ch(0, 1)
qc.cs(0, 1)
# Corresponding multi-controlled versions
qc.mcy([0, 1], 2)
qc.mcz([0, 1], 2)
qc.mch([0, 1], 2)
# Note: there is currently no mcs() (multi-controlled S) method
qc.cp(np.pi / 2, 0, 1)
qc.mcp(np.pi / 2, [0, 1], 2)
# Controlled rotation gates and their multi-controlled versions
qc.crx(np.pi / 2, 0, 1)
qc.cry(np.pi / 2, 0, 1)
qc.crz(np.pi / 2, 0, 1)
qc.mcrx(np.pi / 2, [0, 1], 2)
qc.mcry(np.pi / 2, [0, 1], 2)
qc.mcrz(np.pi / 2, [0, 1], 2)
# SWAP gate
qc.swap(0, 1)
# Arbitrary unitary matrix gate (pass a 2^k x 2^k unitary matrix, target has length k)
import numpy as np
mat = np.array([[0, 1], [1, 0]], dtype=complex) # Pauli-X matrix
qc.unitary(mat, 0)
# unitary also accepts control/control_state
qc.unitary(mat, target=2, control=[0, 1])
# Controlled gate with a specified control state (control_state=0 means it triggers when the control qubit is 0)
qc.cx(0, 1, control_state=0)Common controlled gate quick reference:
| Method | Description | Multi-controlled version |
|---|---|---|
cx(ctrl, tgt) / cnot(ctrl, tgt) | CNOT | mcx(ctrls, tgt) |
cy(ctrl, tgt) | Controlled-Y | mcy(ctrls, tgt) |
cz(ctrl, tgt) | Controlled-Z | mcz(ctrls, tgt) |
ch(ctrl, tgt) | Controlled-H | mch(ctrls, tgt) |
cs(ctrl, tgt) | Controlled-S | None (no mcs) |
cp(angle, ctrl, tgt) | Controlled phase | mcp(angle, ctrls, tgt) |
crx(angle, ctrl, tgt) | Controlled RX | mcrx(angle, ctrls, tgt) |
cry(angle, ctrl, tgt) | Controlled RY | mcry(angle, ctrls, tgt) |
crz(angle, ctrl, tgt) | Controlled RZ | mcrz(angle, ctrls, tgt) |
swap(tgt1, tgt2) | SWAP | — |
unitary(mat, tgt, control, control_state) | Arbitrary unitary matrix, optionally controlled | Built into the same method |
Note: The matrix passed to unitary() follows the little-endian local basis convention, i.e., target[0] corresponds to the least significant qubit in the matrix.
5. Adding Measurements
The integer constructor form Circuit(n) automatically creates a default classical register of the same size (ClassicalRegister('c', n)), so you can call measure() directly without manually creating a classical register first; only when constructing Circuit with an explicit Register and not simultaneously providing a ClassicalRegister do you need to add a classical register first (otherwise the circuit has no classical bits to write to, and calling measure() will raise an error):
from unitarylab import Circuit
# Integer constructor form: a 2-bit classical register 'c' is already created automatically, so you can measure directly
qc = Circuit(2)
qc.h(0)
qc.cx(0, 1)
# Measure qubit 0 into classical bit 0, and qubit 1 into classical bit 1
qc.measure([0, 1], [0, 1])
# Single measurements are also supported
# qc.measure(0, 0)If you construct with an explicit Register and need custom naming/multiple registers/a custom bit layout, you must also pass a ClassicalRegister:
from unitarylab import Circuit, Register, ClassicalRegister
qr = Register('q', 2)
cr = ClassicalRegister('c', 2)
qc = Circuit(qr, cr) # When constructing explicitly, a classical register must also be provided to allow measurement
qc.h(0)
qc.cx(0, 1)
qc.measure([0, 1], [0, 1])Measurement notes:
- In
measure(qubit, clbit), bothqubitandclbitcan be an integer or a list, and their counts must correspond one to one. - Each qubit and classical bit can only be mapped once; mapping the same one more than once raises a
ValueError. - The measurement operation only actually happens when
execute()is run, and the result is written intoExecutionResult.classical_results_map.
6. Executing the Circuit
result = qc.execute()
# Full parameter list
result = qc.execute(
initial_state=None, # initial state; defaults to starting from |0...0⟩
backend='torch', # 'torch' (default) / 'numpy' / 'cpp' / 'tensornet'
device='cpu', # 'cpu' or 'gpu'
dtype=np.complex128, # complex precision, defaults to complex128
shots=1, # number of independent executions
seed=42, # random seed for measurement; None means unfixed
backend_options=None, # tensornet backend only: max_bond / cutoff / routing
)For a full description of execution-related methods and result fields, see Circuit Execution and Tooling Workflow.
7. Drawing and Analysis
# Draw the circuit diagram (pops up a Matplotlib window)
qc.draw()
# Save to a file with a specified title
qc.draw(filename='circuit.png', title='My Circuit')
# Other output formats: 'mpl' (default), 'text', 'latex'
qc.draw(output='text')
# Get the circuit analysis. show=False avoids duplicate printing when info.show() is called afterward.
info = qc.analyze(show=False)
info.show()
# get_matrix() is only applicable to circuits that do not contain non-unitary operations such as measurements.
unitary_qc = Circuit(2)
unitary_qc.h(0)
unitary_qc.cx(0, 1)
matrix = unitary_qc.get_matrix()
# Only compute the top-left 2**m × 2**m sub-block of the unitary matrix (m <= number of qubits), not the "first m rows"
matrix_top = unitary_qc.get_matrix(m=1)For the full parameters of drawing and analysis, and all methods of CircuitInfo, see Circuit Execution and Tooling Workflow.
8. Circuit Transformations
UnitaryLab supports a variety of circuit structure transformations. Among these, copy(), inverse(), dagger(), reverse(), repeat(), decompose(), and control() return new Circuit objects without modifying the original circuit; append(), prepend(), and initialize() directly modify the circuit on which they are called, and return None.
qc = Circuit(2)
qc.h(0)
qc.cx(0, 1)
# Copy the circuit
qc2 = qc.copy()
# Reverse the gate order (and take the conjugate transpose of each gate), equivalent to U† of the whole circuit
qc_inv = qc.inverse()
qc_dag = qc.dagger() # alias for inverse()
# Mirror-flip qubit indices (qubit i ↔ qubit n-1-i); gate execution order is unchanged
qc_rev = qc.reverse()
# Repeat the entire circuit 3 times
qc_rep = qc.repeat(3)
# Unfold block gates (n=1 unfolds one level)
qc_dec = qc.decompose(n=1)
# Add two control qubits, turning the entire circuit into a controlled circuit; control_state='01' means
# the two newly added control qubits must be 0 and 1 respectively to trigger
qc_ctrl = qc.control(num_control_qubits=2, control_state='01')
# append() and prepend() modify the circuit in place. Copies are used to demonstrate each separately,
# so the two operations don't affect each other.
sub = Circuit(2)
sub.z(0)
# Append a sub-circuit to the end of the circuit (inserted as a single block gate)
qc_appended = qc.copy()
qc_appended.append(sub, target=[0, 1])
# Prepend a sub-circuit (inserted at the very beginning of the circuit)
qc_prepended = qc.copy()
qc_prepended.prepend(sub, target=[0, 1])
# Prepare the target qubit into a specified statevector (internally decomposed into a sequence of RY/CX gates);
# initialize() modifies the circuit in place, and the target qubit must not have been used by any prior gate.
# Therefore, use a new circuit and call initialize() before any other quantum gates.
import numpy as np
v = np.array([1 / np.sqrt(2), 1 / np.sqrt(2)])
qc_initialized = Circuit(2)
qc_initialized.initialize(v, target=0)
qc_initialized.cx(0, 1)Circuit transformation quick reference:
| Method | Description |
|---|---|
copy() | Returns an independent copy of the circuit |
inverse() / dagger() | Reverses order and takes the conjugate transpose (aliases of each other) |
reverse() | Mirror-flips qubit indices |
repeat(times) | Repeats the entire circuit times times |
decompose(n, name) | Unfolds block gates; n is the number of levels to unfold (default 1) |
control(num_control_qubits, control_state) | Wraps the circuit as a controlled circuit |
append(other, target, control, control_state) | Appends a sub-circuit to the end of the circuit in place, returns None |
prepend(other, target, control, control_state) | Inserts a sub-circuit at the very beginning of the circuit in place, returns None |
initialize(v, target, control, control_state) | Initializes the target qubit to a specified state in place, returns None; requires the target qubit to be unused |
9. OpenQASM and Python Source Code Import/Export
Circuit directly provides import/export methods for OpenQASM 2.0 / 3.0 as well as Python source code. Below are the common usages of the public interface; for the underlying GateSequence conversion, supported gate range, and interoperability limitations, see Circuit Execution and Tooling Workflow.
qc = Circuit(2)
qc.h(0)
qc.cx(0, 1)
# Export as a string (OpenQASM 3.0 by default)
qasm3_str = qc.to_qasm()
qasm2_str = qc.to_qasm2()
# Restore from a string, automatically detecting 2.0 / 3.0
qc2 = Circuit.from_qasm(qasm3_str)
# File read/write
qc.to_qasm_file('circuit.qasm')
qc3 = Circuit.from_qasm_file('circuit.qasm')Export parameter notes (to_qasm() / to_qasm_file()):
decompose: ifTrue(or a positive integer),decompose()is called to unfold block gates before exporting.transpile: ifTrue, the circuit is transpiled to a basis gate set before exporting (used to export gates that QASM cannot directly represent, such as a customunitarygate that carries no matrix information).- When the circuit contains a gate that is not supported for direct export, a
NotImplementedErroris raised. For the specific supported range,unitaryextensions, and third-party tool compatibility, see OpenQASM Supported Range and Limitations.
Important limitation of to_python(): The current gate-to-Python-statement conversion (unitarylab.codegen.CodeGenerator) only covers the five gates rx, ry, rz, p, and cx. If the circuit contains any other gate (such as h), calling to_python() will raise RuntimeError("cannot reliably export UnitaryLab gate '...' as native code"), rather than degrading gracefully.
The default transpile() also cannot solve this limitation: the default basis gate set itself includes h, so qc.transpile() will keep h, and calling to_python() afterward will still raise the same exception. For circuits containing gates not covered by CodeGenerator, it is recommended to use to_qasm() / to_qasm2(); only use to_python() directly when the circuit is already composed solely of rx, ry, rz, p, and cx.
# A circuit containing an h gate cannot be exported directly to Python; the default transpile() still keeps h:
try:
qc.transpile().to_python()
except RuntimeError as e:
print(e) # cannot reliably export UnitaryLab gate 'h' as native code
# An exportable circuit must use only the gates currently supported by CodeGenerator.
# Below, P(π) followed by RY(π/2) is equivalent to H, so this circuit is equivalent to the qc above.
import numpy as np
codegen_qc = Circuit(2)
codegen_qc.p(np.pi, 0)
codegen_qc.ry(np.pi / 2, 0)
codegen_qc.cx(0, 1)
python_src = codegen_qc.to_python()
print(python_src)Import/export method quick reference:
| Method | Description |
|---|---|
to_qasm(qreg_name, creg_name, decompose, transpile, gates_to_unroll, transpile_basis) | Exports an OpenQASM 3.0 string |
to_qasm2(qreg_name, creg_name) | Exports an OpenQASM 2.0 string |
Circuit.from_qasm(qasm_code) | Builds a Circuit from QASM source code (class method, automatically detects version) |
Circuit.from_qasm_file(filepath) | Builds a Circuit from a QASM file (class method) |
to_qasm_file(filepath, ...) | Exports QASM 3.0 to a file |
to_python(decompose, transpile, circuit_name, variable_name) | Exports as a Python source code string |
to_python_file(filepath, ...) | Exports Python source code to a file |
transpile(gates_to_unroll, basis) | Transpiles to a basis gate set (default basis='default') |
Register Interface
Register
A quantum register that supports Python-style indexing:
from unitarylab import Register
qr = Register('q', 3)
# Single-qubit access
print(qr[0])
# Slice access
print(qr[1:3])
# List access
print(qr[[0, 2]])ClassicalRegister
A classical register with an interface symmetric to Register. The values attribute stores measurement results, with -1 indicating unmeasured:
from unitarylab import ClassicalRegister
cr = ClassicalRegister('c', 2)
print(cr.values) # [-1, -1]Common Notes
- Circuit reuse:
Circuitobjects can be nested viaappend/prepend, which is very useful when building modular algorithms (e.g., QPE, QFT + algorithm body). - Immutable gate objects:
QuantumGateis a frozen dataclass; once created it cannot be modified. All circuit transformation methods return new objects and do not modify the original circuit. - Block gate unfolding: Sub-circuits added via
append/prepend/initializeare stored as block gates (block). They are automatically unfolded during execution, but for drawing and analysis you may need to calldecompose()first to see the details. - Gate method naming: The Python API method names use
sdag/tdag(e.g.,qc.sdag(0)), while the corresponding gate names in exported QASM text aresdg/tdg(the OpenQASM standard naming) — these are two different naming conventions for two different contexts, not an inconsistency, and should not be mixed up. - Limited gate coverage of
to_python(): See the “OpenQASM and Python Source Code Import/Export” section above; the defaulttranspile()does not unfold default basis gates such ash, and does not guarantee satisfying CodeGenerator’s gate set restrictions.