Skip to Content

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 Circuit objects 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

ModuleMain classes
unitarylab.core.circuitCircuitBase, Circuit
unitarylab.core.registerRegister
unitarylab.core.classical_registerClassicalRegister
unitarylab.backend.gatesequence.gatesequenceGateSequenceBase, GateSequence
unitarylab.backend.gate.gatebaseQuantumGate

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.
  • Circuit internally maintains a GateSequence automatically; each time a gate method is called, a QuantumGate is appended to it (QuantumGate is 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) separately

Common single-qubit gate quick reference:

MethodGate nameDescription
x(target)Pauli-XQuantum NOT gate; flips to
y(target)Pauli-YRotation by π about the Y axis
z(target)Pauli-ZPhase-flips
h(target)HadamardCreates 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)IdentityIdentity 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)
MethodParametersDescription
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:

MethodDescriptionMulti-controlled version
cx(ctrl, tgt) / cnot(ctrl, tgt)CNOTmcx(ctrls, tgt)
cy(ctrl, tgt)Controlled-Ymcy(ctrls, tgt)
cz(ctrl, tgt)Controlled-Zmcz(ctrls, tgt)
ch(ctrl, tgt)Controlled-Hmch(ctrls, tgt)
cs(ctrl, tgt)Controlled-SNone (no mcs)
cp(angle, ctrl, tgt)Controlled phasemcp(angle, ctrls, tgt)
crx(angle, ctrl, tgt)Controlled RXmcrx(angle, ctrls, tgt)
cry(angle, ctrl, tgt)Controlled RYmcry(angle, ctrls, tgt)
crz(angle, ctrl, tgt)Controlled RZmcrz(angle, ctrls, tgt)
swap(tgt1, tgt2)SWAP
unitary(mat, tgt, control, control_state)Arbitrary unitary matrix, optionally controlledBuilt 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), both qubit and clbit can 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 into ExecutionResult.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:

MethodDescription
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: if True (or a positive integer), decompose() is called to unfold block gates before exporting.
  • transpile: if True, the circuit is transpiled to a basis gate set before exporting (used to export gates that QASM cannot directly represent, such as a custom unitary gate that carries no matrix information).
  • When the circuit contains a gate that is not supported for direct export, a NotImplementedError is raised. For the specific supported range, unitary extensions, 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:

MethodDescription
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: Circuit objects can be nested via append / prepend, which is very useful when building modular algorithms (e.g., QPE, QFT + algorithm body).
  • Immutable gate objects: QuantumGate is 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 / initialize are stored as block gates (block). They are automatically unfolded during execution, but for drawing and analysis you may need to call decompose() 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 are sdg/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 default transpile() does not unfold default basis gates such as h, and does not guarantee satisfying CodeGenerator’s gate set restrictions.
Last updated on