Source code for qiskit_cold_atom.base_circuit_solver

# This code is part of Qiskit.
#
# (C) Copyright IBM 2021.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
#
# Any modifications or derivative works of this code must retain this
# copyright notice, and modified files need to carry a notice indicating
# that they have been altered from the originals.

"""A base class for circuit solvers of cold atomic quantum circuits."""

from abc import ABC, abstractmethod
from typing import List, Optional, Dict, Any
import warnings
import numpy as np
from scipy.sparse import csc_matrix, identity, SparseEfficiencyWarning
from scipy.sparse.linalg import expm

from qiskit import QuantumCircuit
from qiskit.circuit import Gate
from qiskit_nature.operators.second_quantization import SecondQuantizedOp
from qiskit_cold_atom.exceptions import QiskitColdAtomError


class BaseCircuitSolver(ABC):
    """An abstract class for circuit solvers of different cold atom types.

    By subclassing BaseCircuitSolver one can create circuit solvers for different
    types of cold atomic setups such as spin, fermionic, and bosonic setups. All
    these subclasses will simulate cold atom quantum circuits by exponentiating
    matrices. Therefore, subclasses of BaseCircuitSolver are not intended to solve
    large circuits.
    """

    def __init__(
        self,
        shots: Optional[int] = None,
        seed: Optional[int] = None,
        max_dimension: int = 1e6,
        ignore_barriers: bool = True,
    ):
        """
        Args:
            shots: amount of shots for the measurement simulation;
                   if not None, measurements are performed, otherwise no measurements are done.
            seed: seed for the RNG for the measurement simulation
            max_dimension: The maximum Hilbert space dimension (limited to keep
                computation times reasonably short)
            ignore_barriers: If true, will ignore barrier instructions
        """
        self.shots = shots

        self._seed = seed
        if self._seed is not None:
            np.random.seed(self._seed)

        self._max_dimension = max_dimension
        self._ignore_barriers = ignore_barriers
        self._dim = None

    @property
    def seed(self) -> int:
        """The seed for the random number generator of the measurement simulation."""
        return self._seed

    @seed.setter
    def seed(self, value: int):
        """Set the seed for the random number generator. This will also update numpy's seed."""
        np.random.seed(value)
        self._seed = value

    @property
    def max_dimension(self) -> int:
        """The maximal Hilbert space dimension of the simulation."""
        return self._max_dimension

    @max_dimension.setter
    def max_dimension(self, value: int):
        self._max_dimension = value

    @property
    def ignore_barriers(self) -> bool:
        """Boolean flag that defines how barrier instructions in the circuit are handled."""
        return self._ignore_barriers

    @property
    def dim(self) -> int:
        """Return the dimension set by the last quantum circuit on which the solver was called."""
        return self._dim

    @ignore_barriers.setter
    def ignore_barriers(self, boolean):
        self._ignore_barriers = boolean

    def __call__(self, circuit: QuantumCircuit) -> Dict[str, Any]:
        """
        Performs the simulation of the circuit: Each operator is converted into a sparse matrix
        over the basis and is then exponentiated to get the unitary of the gate. All these
        unitaries are multiplied to give the total unitary of the circuit. Applying this to the
        initial state yields the final state of the circuit, from which we sample a number `shots`
        of shots (if specified).

        Args:
            circuit: A quantum circuit with gates described by second quantized generators

        Returns:
            output: dict{'unitary' : np.array((dimension, dimension)),
                 'statevector': np.array((dimension, 1)),
                 'counts': dict{string: int}}

        Raises:
            QiskitColdAtomError:
                - If one of the generating Hamiltonians is not hermitian which would
                lead to non-unitary time evolution.
                - If the dimension of the Hilbert space is larger than the max. dimension.
            NotImplementedError:
                - If ignore_barriers is False.
        """

        self.preprocess_circuit(circuit)

        if self._dim > self.max_dimension:
            raise QiskitColdAtomError(
                f"Hilbert space dimension of the simulation ({self._dim}) exceeds the "
                f"maximally supported value {self.max_dimension}."
            )

        # initialize the circuit unitary as an identity matrix
        circuit_unitary = identity(self._dim, dtype=complex)

        for op in self.to_operators(circuit):
            operator_mat = self.operator_to_mat(op)

            # check that the operators are hermitian before exponentiating
            if (operator_mat.H - operator_mat).count_nonzero() != 0:
                raise QiskitColdAtomError("generator of unitary gate is not hermitian!")
            # with the next release of qiskit nature this can be replaced with
            # if not operator.is_hermitian():
            #     raise QiskitColdAtomError("generator of unitary gate is not hermitian!")

            with warnings.catch_warnings():
                warnings.filterwarnings("ignore", category=SparseEfficiencyWarning)
                gate_unitary = expm(-1j * operator_mat)

            circuit_unitary = gate_unitary @ circuit_unitary

        final_state = circuit_unitary @ self.get_initial_state(circuit)

        output = {
            "unitary": circuit_unitary.toarray(),
            "statevector": final_state.toarray().reshape(self._dim),
        }

        # If shots is specified, simulate measurements on the entire register!
        if self.shots is not None:
            meas_distr = np.abs(final_state.toarray().reshape(self._dim)) ** 2

            if not np.isclose(sum(meas_distr), 1.0):
                raise QiskitColdAtomError("Final statevector is not normalized")

            meas_results = self.draw_shots(meas_distr)
            counts_dict = {
                outcome: list(meas_results).count(outcome) for outcome in set(meas_results)
            }
            output["memory"] = meas_results
            output["counts"] = counts_dict

        # return empty memory and counts dictionary if no shots are specified
        else:
            output["memory"] = []
            output["counts"] = {}

        return output

    def to_operators(self, circuit: QuantumCircuit) -> List[SecondQuantizedOp]:
        """
        Convert a circuit to a list of second quantized operators that describe the generators of the
        gates applied to the circuit. The SecondQuantizedOps generating the gates are embedded in the
        larger space corresponding to the entire circuit.

        Args:
            circuit: A quantum circuit with gates described by second quantized generators

        Returns:
            operators: a list of second-quantized operators, one for each applied gate, in the order
            of the gates in the circuit

        Raises:
            QiskitColdAtomError: - If a given gate can not be converted into a second-quantized operator
                         - If a gate is applied after a measurement instruction
                         - If a circuit instruction other than a Gate, measure, load or barrier is given
            NotImplementedError: If ignore_barriers is False
        """
        operators = []
        measured = [False] * circuit.num_qubits

        for inst in circuit.data:
            name = inst[0].name
            qargs = [circuit.qubits.index(qubit) for qubit in inst[1]]

            if name == "measure":
                for idx in qargs:
                    measured[idx] = True

            elif name == "load":
                continue

            elif name == "barrier":
                if self.ignore_barriers:
                    continue
                raise NotImplementedError

            elif isinstance(inst[0], Gate):
                try:
                    second_quantized_op = inst[0].generator
                except AttributeError as attribute_error:
                    raise QiskitColdAtomError(
                        f"Gate {inst[0].name} has no defined generator"
                    ) from attribute_error

                if not isinstance(second_quantized_op, SecondQuantizedOp):
                    raise QiskitColdAtomError(
                        "Gate generator needs to be initialized as qiskit_nature SecondQuantizedOp"
                    )
                for idx in qargs:
                    if measured[idx]:
                        raise QiskitColdAtomError(
                            f"Simulator cannot handle gate {name} after previous measure instruction."
                        )

                if not second_quantized_op.register_length == len(qargs):
                    raise QiskitColdAtomError(
                        f"length of operator labels {second_quantized_op.register_length} must be "
                        f"equal to length of wires {len(qargs)} the gate acts on"
                    )
                operators.append(
                    self._embed_operator(second_quantized_op, circuit.num_qubits, qargs)
                )

            else:
                raise QiskitColdAtomError(f"Unknown instruction {name} applied to circuit")

        return operators

    @abstractmethod
    def get_initial_state(self, circuit: QuantumCircuit) -> csc_matrix:
        """Returns the initial state of the quantum circuit as a sparse column vector."""

    @abstractmethod
    def _embed_operator(
        self, operator: SecondQuantizedOp, num_wires: int, qargs: List[int]
    ) -> SecondQuantizedOp:
        """
        Turning an operator that acts on the wires given in qargs into an operator
        that acts on the entire state space of a circuit. The implementation of the subclasses
        depends on whether the operators use sparse labels (SpinOp) or dense labels (FermionicOp).

        Args:
            operator: SecondQuantizedOp describing the generating Hamiltonian of a gate
            num_wires: number of wires of the space in which to embed the operator
            qargs: The wire indices the gate acts on

        Returns: A SecondQuantizedOp acting on the entire quantum register of the Circuit
        """

    @abstractmethod
    def operator_to_mat(self, operator: SecondQuantizedOp) -> csc_matrix:
        """Turn a SecondQuantizedOp into a sparse matrix."""

    @abstractmethod
    def preprocess_circuit(self, circuit: QuantumCircuit):
        """Pre-process the circuit, e.g. initialize the basis and validate.
        This needs to update ``dim``, i.e. the Hilbert space dimension of the solver."""

    @abstractmethod
    def draw_shots(self, measurement_distribution: List[float]) -> List[str]:
        """
        Simulates shots by drawing from a given distribution of measurement outcomes.
        Assigning the index of each outcome to the occupations of the modes can be
        non-trivial which is why this step needs to be implemented by the subclasses.

        Args:
            measurement_distribution: A list with the probabilities of the different
                measurement outcomes that has the length of the Hilbert space dimension.

        Returns:
            A list of strings encoding the outcome of the individual shots.
        """