Source code for NoisyCircuits.QuantumCircuit

"""
This module allows users to create and simulate quantum circuits with noise models based on IBM quantum machines. It provides methods for adding gates, executing the circuit with Monte-Carlo simulations, and visualizing the circuit. It considers both single and two-qubit gate errors as well as measurement errors.\n

Example:\n
    >>> from NoisyCircuits.QuantumCircuit import QuantumCircuit
    >>> circuit = QuantumCircuit(num_qubits=3, noise_model=my_noise_model, backend_qpu_type='Heron', num_trajectories=1000)
    >>> circuit.h(0)
    >>> circuit.cx(0, 1)
    >>> circuit.cx(1, 2)
    >>> circuit.run_with_density_matrix(qubits=[0, 1, 2]) # Executes the circuit using the density matrix solver
    [0.39841323, 0.00300163, 0.09303931, 0.00615167, 0.00616272, 0.09281154, 0.00300024, 0.39741967]
    >>> circuit.execute(qubits=[0, 1, 2]) # Executes the circuit using the Monte-Carlo Wavefunction method
    [0.39748485, 0.0037614 , 0.09168292, 0.00799886, 0.00746056, 0.09156762, 0.00367236, 0.39637143]
    >>> circuit.run_pure_state(qubits=[0, 1, 2]) # Executes the circuit using the pure state solver
    [0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5]
    >>> circuit.shutdown() # Shutdown the Ray parallel execution environment
"""
import os
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"

import numpy as np
from NoisyCircuits.utils.BuildQubitGateModel import BuildModel
from NoisyCircuits.utils.EagleDecomposition import EagleDecomposition
from NoisyCircuits.utils.HeronDecomposition import HeronDecomposition
from NoisyCircuits.utils.solvers import load_solver
from NoisyCircuits.utils.marginal_probs import compute_marginal_probs
import json
import ray
import gc


[docs] class QuantumCircuit: r""" This class allows a user to create a quantum circuit with error model from IBM machines where selected gates (both parameterized and non-parameterized) are implemented as methods. The gate decomposition uses the basis gates of the IBM Eagle (:math:`\sqrt{X}`, :math:`X`, :math:`R_Z(\theta)` and :math:`ECR`) / Heron (:math:`\sqrt{X}`, :math:`X`, :math:`R_Z(\theta)`, :math:`R_X(\theta)`, :math:`CZ` and :math:`RZZ(\theta)`) QPUs. Currently, it is only possible to apply a limited selection of single and two-qubit gates to the circuit simulation. For a full list of supported gates, please refer to the Decomposition :func:`NoisyCircuits.utils.Decomposition` class documentation. Args: num_qubits (int): The number of qubits in the circuit. noise_model (dict): The noise model to be used for the circuit. backend_qpu_type (str): The IBM Backend Architecture type to be used (Eagle or Heron). num_trajectories (int): The number of trajectories for the Monte-Carlo simulation. num_cores (int, optional): The number of cores to use for parallel execution. Defaults to 2. sim_backend (str, optional): The simulation backend to use, either 'qulacs', 'pennylane', or 'qiskit'. Defaults to 'pennylane'. jsonize (bool, optional): If True, the circuit will be serialized to JSON format. Defaults to False. verbose (bool, optional): If False, suppresses detailed output during initialization. Defaults to True. threshold (float, optional): The threshold for noise application. Defaults to 1e-12. Raises: TypeError: Raised when the input parameters are of incorrect types. - num_qubits is not an integer. - noise_model is not a dictionary. - backend_qpu_type is not a string. - num_trajectories is not an integer. - num_cores is not an integer. - sim_backend is not a string. - jsonize is not a boolean. - verbose is not a boolean. ValueError: Raised when the input parameters have invalid values. - num_qubits is not a positive integer. - backend_qpu_type is not one of the supported types (Eagle or Heron). - num_trajectories is less than 1. - threshold is not between 0 and 1 (exclusive). - num_cores is less than 1 or exceeds available CPU cores. - sim_backend is not one of the supported backends (qulacs, pennylane, qiskit). """ # Update QPU Basis Gates Here! basis_gates_set = { 'eagle': { "basis_gates" : [['rz', 'sx', 'x'], ['ecr']], "gate_decomposition" : EagleDecomposition }, 'heron': { "basis_gates" : [['rz', 'rx', 'sx', 'x'], ['cz', 'rzz']], "gate_decomposition" : HeronDecomposition } } available_sim_backends = ["qulacs", "pennylane", "qiskit"] def __init__(self, num_qubits:int, noise_model:dict, backend_qpu_type:str, num_trajectories:int, num_cores:int=2, sim_backend:str="qulacs", jsonize:bool=False, verbose:bool=True, threshold:float=1e-12)->None: """ Initializes the QuantumCircuit with the specified number of qubits, noise model, number of trajectories for Monte-Carlo simulation, and threshold for noise application. """ if not isinstance(num_qubits, int): raise TypeError("Number of qubits must be an integer.") if num_qubits <= 0: raise ValueError("Number of qubits must be a positive integer.") if not isinstance(noise_model, dict): raise TypeError("Noise model must be a dictionary.") if not isinstance(backend_qpu_type, str): raise TypeError("Backend QPU type must be a string.") if backend_qpu_type.lower() not in list(QuantumCircuit.basis_gates_set.keys()): raise ValueError(f"Backend QPU type must be one of {list(QuantumCircuit.basis_gates_set.keys())}.") if not isinstance(num_trajectories, int): raise TypeError("Number of trajectories must be an integer.") if num_trajectories < 1: raise ValueError("Number of trajectories must be a positive integer greater than or equal to 1.") if not isinstance(threshold, float): raise TypeError("Threshold must be a float.") if 0 >= threshold or threshold >= 1: raise ValueError("Threshold must be a float between 0 and 1 (exclusive).") if not isinstance(num_cores, int): raise TypeError("Number of cores must be an integer.") if not isinstance(sim_backend, str): raise TypeError("Simulation backend must be a string.") if sim_backend.lower() not in QuantumCircuit.available_sim_backends: raise ValueError(f"Simulation backend must be one of {QuantumCircuit.available_sim_backends}.") if num_cores < 1: raise ValueError("Number of cores must be a positive integer greater than or equal to 1.") if not isinstance(jsonize, bool): raise TypeError("Jsonize must be a boolean.") if not isinstance(verbose, bool): raise TypeError("Verbose must be a boolean.") if num_cores > os.cpu_count(): raise ValueError(f"Number of cores cannot exceed available CPU cores ({os.cpu_count()}).") self.num_qubits = num_qubits self.noise_model = noise_model if jsonize: self.noise_model = json.JSONDecoder().decode(json.dumps(noise_model)) self.num_trajectories = num_trajectories self.threshold = threshold self.num_cores = num_cores self._sim_backend = None self.solver = None self.sim_backend = sim_backend self.verbose = verbose self.qpu = backend_qpu_type.lower() basis_gates = QuantumCircuit.basis_gates_set[self.qpu]["basis_gates"] modeller = BuildModel( noise_model=self.noise_model, num_qubits=self.num_qubits, num_cores=self.num_cores, threshold=self.threshold, basis_gates=basis_gates, verbose=self.verbose ) single_error, multi_error, measure_error, connectivity = modeller.build_qubit_gate_model() self.single_qubit_instructions = single_error self.two_qubit_instructions = multi_error self.measurement_error = measure_error self.connectivity = connectivity single_qubit_instructions_array = np.array(list(self.single_qubit_instructions.items())) two_qubit_instructions_array = np.array(list(self.two_qubit_instructions.items())) self.qubit_coupling_map = modeller.qubit_coupling_map self.measurement_error_operator = self._generate_measurement_error_operator() self._gate_decomposer = QuantumCircuit.basis_gates_set[self.qpu]["gate_decomposition"]( num_qubits=self.num_qubits, connectivity=self.connectivity, qubit_map=self.qubit_coupling_map ) ray.init(num_cpus=self.num_cores, ignore_reinit_error=True, log_to_driver=False) self._single_qubit_instruction_reference = ray.put(single_qubit_instructions_array) self._two_qubits_instruction_reference = ray.put(two_qubit_instructions_array) self._two_qubit_gate_index = {two_qubit_instructions_array[i][0] : i for i in range(len(two_qubit_instructions_array))} self.workers = [ self.solver.RemoteExecutor.remote( num_qubits = self.num_qubits, single_qubit_noise = self._single_qubit_instruction_reference, two_qubit_noise = self._two_qubits_instruction_reference, two_qubit_noise_index = self._two_qubit_gate_index ) for _ in range(self.num_cores) ] @property def sim_backend(self)->str: """ Getter for the _sim_backend attribute Returns: (str): Returns the current sim_backend value. """ return self._sim_backend @sim_backend.setter def sim_backend(self, backend:str)->None: """ Setter for the _sim_backend attribute and updates the solver modules. Args: backend (str): The name of the backend. Raises: TypeError: Raised when backend is not a string ValueError: Raised when the specified backend is not available. """ if not isinstance(backend, str): raise TypeError("Specified backend must of type string") if backend not in QuantumCircuit.available_sim_backends: raise ValueError(f"Specified backend {backend} is not available. Choose from {QuantumCircuit.available_sim_backends}.") if backend == self._sim_backend: print("Backend already in use.") return new_solver = load_solver(backend) self.solver = new_solver self._sim_backend = backend print("Successfully switched backend to {}.".format(backend)) def __getattr__(self, name: str) -> callable: """ Delegate unknown attributes/methods to the selected methods class. """ if name is not None: return getattr(self._gate_decomposer, name)
[docs] def refresh(self): """ Resets the quantum circuit by clearing the instruction list and qubit-to-instruction mapping. """ self._gate_decomposer.instruction_list = []
def _generate_measurement_error_operator(self, qubit_list:list[int]=None)->np.ndarray: """ Generates the measurement error operator for the specified qubits. Args: qubit_list (list[int], optional): The list of qubits to include in the measurement error operator. If None, includes all qubits. Defaults to None. Returns: np.ndarray: The measurement error operator as a numpy array. Returns None if there are no measurement errors. """ if qubit_list is None: measure_qubits = list(range(self.num_qubits)) else: measure_qubits = qubit_list if self.measurement_error == {}: return None for qubit_number, qubit in enumerate(measure_qubits): if qubit_number == 0: meas_error_op = self.measurement_error[qubit] else: meas_error_op = np.kron(meas_error_op, self.measurement_error[qubit]) return meas_error_op
[docs] def execute(self, qubits:list[int], num_trajectories:int=None)->np.ndarray: """ Executes the built quantum circuit with the specified noise model using the Monte-Carlo Wavefunction method. Args: qubits (list[int]): The list of qubits to be measured. num_trajectories (int): The number of trajectories for the Monte-Carlo simulation (can be modified). Defaults to None and uses the class attribute. If specified, it overrides the class attribute for only this execution. Defaults to None. Raises: TypeError: If qubits is not a list or the items in the list are not integers. TypeError: If num_trajectories is not an integer. ValueError: If num_trajectories is less than 1. ValueError: If qubits contains invalid qubit indices. ValueError: If there are no instructions in the circuit to execute. Returns: np.ndarray: The probabilities of the output states. """ if num_trajectories is not None: if not isinstance(num_trajectories, int): raise TypeError("Number of trajectories must be an integer.") if num_trajectories < 1: raise ValueError("Number of trajectories must be a positive integer greater than or equal to 1.") if not isinstance(qubits, list) or any(not isinstance(qubit, int) for qubit in qubits): raise TypeError("qubits must be of type list.\nAll entries in qubits must be integers.") if any((qubit < 0 or qubit >= self.num_qubits) for qubit in qubits): raise ValueError(f"One or more qubits are out of range. The valid range is from 0 to {self.num_qubits - 1}.") if self.instruction_list == []: raise ValueError("No instructions in the circuit to execute.") if num_trajectories is None: num_trajectories = self.num_trajectories if len(qubits) != self.num_qubits: measurement_error_operator = self._generate_measurement_error_operator(qubit_list=qubits) else: measurement_error_operator = self.measurement_error_operator reset_probs = [ self.workers[i].reset.remote(measured_qubits=qubits) for i in range(self.num_cores) ] futures = [ self.workers[traj_id % self.num_cores].run.remote(traj_id, self.instruction_list) for traj_id in range(num_trajectories) ] prob_chunks = [ ray.get(self.workers[i].get.remote(qubits)) for i in range(self.num_cores) ] probs = np.array(prob_chunks).sum(axis=0) / num_trajectories if self._sim_backend not in ["pennylane"]: probs = probs.reshape([2]*self.num_qubits).transpose(list(range(self.num_qubits))[::-1]).reshape(-1) if len(qubits) != self.num_qubits and self._sim_backend not in ["pennylane"]: trace_qubits = [i for i in range(self.num_qubits) if i not in qubits] probs_reduced = compute_marginal_probs(probs, trace_qubits) probs = probs_reduced if measurement_error_operator is not None: probs = np.dot(measurement_error_operator, probs) return probs
[docs] def run_with_density_matrix(self, qubits:list[int])->np.ndarray: """ Runs the quantum circuit with the density matrix solver. Args: qubits (list[int]): List of qubits to be simulated. Raises: TypeError: If qubits is not a list or the items in the list are not integers. ValueError: If qubits contains invalid qubit indices. ValueError: If there are no instructions in the circuit to execute. Returns: np.ndarray: Probabilities of the output states. """ if not isinstance(qubits, list) or any(not isinstance(q, int) for q in qubits): raise TypeError("Qubits must be a list of integers.") if any((qubit < 0 or qubit >= self.num_qubits) for qubit in qubits): raise ValueError(f"One or more qubits are out of range. The valid range is from 0 to {self.num_qubits - 1}.") if self.instruction_list == []: raise ValueError("No instructions in the circuit to execute.") if len(qubits) != self.num_qubits: measurement_error_operator = self._generate_measurement_error_operator(qubit_list=qubits) else: measurement_error_operator = self.measurement_error_operator density_matrix_solver = self.solver.DensityMatrixSolver( num_qubits=self.num_qubits, single_qubit_noise=self.single_qubit_instructions, two_qubit_noise=self.two_qubit_instructions, instruction_list=self.instruction_list ) probs = density_matrix_solver.solve(qubits=qubits) if self._sim_backend not in ["pennylane"]: m = len(qubits) probs = probs.reshape([2]*m).transpose(list(range(m))[::-1]).reshape(-1) if measurement_error_operator is not None: probs = np.dot(measurement_error_operator, probs) return probs
[docs] def run_pure_state(self, qubits:list[int])->np.ndarray: """ Runs the quantum circuit with the pure state solver. Args: qubits (list[int]): List of qubits to be simulated. Raises: TypeError: If qubits is not a list or the items in the list are not integers. ValueError: If qubits contains invalid qubit indices. ValueError: If there are no instructions in the circuit to execute. Returns: np.ndarray: Probabilities of the output states. """ if not isinstance(qubits, list) or any(not isinstance(q, int) for q in qubits): raise TypeError("Qubits must be a list of integers.") if self.instruction_list == []: raise ValueError("No instructions in the circuit to execute.") if any((qubit < 0 or qubit >= self.num_qubits) for qubit in qubits): raise ValueError(f"One or more qubits are out of range. The valid range is from 0 to {self.num_qubits - 1}.") pure_state_solver = self.solver.PureStateSolver( num_qubits=self.num_qubits, instruction_list=self.instruction_list ) probs = pure_state_solver.solve(qubits=qubits) if self._sim_backend not in ["pennylane"]: probs = probs.reshape([2]*self.num_qubits).transpose(list(range(self.num_qubits))[::-1]).reshape(-1) if len(qubits) != self.num_qubits and self._sim_backend not in ["pennylane"]: trace_qubits = [i for i in range(self.num_qubits) if i not in qubits] probs_reduced = compute_marginal_probs(probs, trace_qubits) probs = probs_reduced return probs
[docs] def draw_circuit(self, style:str="mpl")->None: """ Draws the quantum circuit. Args: style (str, optional): The style of the drawing, either 'mpl' for matplotlib or 'text' for text-based representation. Defaults to 'mpl'. Raises: TypeError: If style is not a string. ValueError: If style is not one of ['mpl', 'text']. ValueError: If there are no instructions in the circuit to draw. """ if not isinstance(style, str): raise TypeError("Style must be a string.") if style.lower() not in ["mpl", "text"]: raise ValueError("Style must be one of ['mpl', 'text'].") if self.instruction_list == []: raise ValueError("No instructions in the circuit to draw.") from qiskit import QuantumCircuit as QiskitQuantumCircuit import matplotlib.pyplot as plt circuit = QiskitQuantumCircuit(self.num_qubits) instruction_map = { "x": lambda q, p: circuit.x(q[0]), "sx": lambda q, p: circuit.sx(q[0]), "rz": lambda q, p: circuit.rz(p, q[0]), "rx": lambda q, p: circuit.rx(p, q[0]), "cz": lambda q, p: circuit.cz(q[0], q[1]), "ecr": lambda q, p: circuit.ecr(q[0], q[1]), "rzz": lambda q, p: circuit.rzz(p, q[0], q[1]), "unitary": lambda q, p: circuit.unitary(p, q) } for gate_name, qubit_index, parameters in self.instruction_list: instruction_map[gate_name](qubit_index, parameters) if style.lower() == "mpl": circuit.draw(output="mpl") plt.show() else: print(circuit.draw()) del circuit gc.collect()
[docs] def shutdown(self): """ Shutsdown the Ray parallel execution environment. """ ray.shutdown()