Source code for qiskit.algorithms.time_evolvers.variational.var_qte

# This code is part of Qiskit.
#
# (C) Copyright IBM 2023.
#
# 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.

"""The Variational Quantum Time Evolution Interface"""
from __future__ import annotations

from abc import ABC
from collections.abc import Mapping, Callable, Sequence
from typing import Type

import numpy as np
from scipy.integrate import OdeSolver

from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
from qiskit.opflow import PauliSumOp
from qiskit.primitives import BaseEstimator
from qiskit.quantum_info.operators.base_operator import BaseOperator

from .solvers.ode.forward_euler_solver import ForwardEulerSolver
from .solvers.ode.ode_function_factory import OdeFunctionFactory
from .solvers.ode.var_qte_ode_solver import VarQTEOdeSolver
from .solvers.var_qte_linear_solver import VarQTELinearSolver

from .variational_principles.variational_principle import VariationalPrinciple
from .var_qte_result import VarQTEResult

from ..time_evolution_problem import TimeEvolutionProblem

from ...observables_evaluator import estimate_observables


class VarQTE(ABC):
    """Variational Quantum Time Evolution.

    Algorithms that use variational principles to compute a time evolution for a given
    Hermitian operator (Hamiltonian) and a quantum state prepared by a parameterized quantum
    circuit.

    Attributes:
            ansatz (QuantumCircuit): Ansatz to be used for variational time evolution.
            initial_parameters (Mapping[Parameter, float] | Sequence[float]): Initial
                parameter values for an ansatz.
            variational_principle (VariationalPrinciple): Variational Principle to be used.
            estimator (BaseEstimator): An estimator primitive used for calculating expectation
                values of ``TimeEvolutionProblem.aux_operators``.
            ode_solver(Type[OdeSolver] | str): ODE solver callable that implements a SciPy
                ``OdeSolver`` interface or a string indicating a valid method offered by SciPy.
            lse_solver (Callable[[np.ndarray, np.ndarray], np.ndarray] | None): Linear system
                of equations solver callable. It accepts ``A`` and ``b`` to solve ``Ax=b``
                and returns ``x``.
            num_timesteps (int | None): The number of timesteps to take. If None, it is
                automatically selected to achieve a timestep of approximately 0.01. Only
                relevant in case of the ``ForwardEulerSolver``.
            imag_part_tol (float): Allowed value of an imaginary part that can be neglected if no
                imaginary part is expected.
            num_instability_tol (float): The amount of negative value that is allowed to be
                rounded up to 0 for quantities that are expected to be
                non-negative.
    References:

        [1] Benjamin, Simon C. et al. (2019).
        Theory of variational quantum simulation. `<https://doi.org/10.22331/q-2019-10-07-191>`_
    """

    def __init__(
        self,
        ansatz: QuantumCircuit,
        initial_parameters: Mapping[Parameter, float] | Sequence[float],
        variational_principle: VariationalPrinciple,
        estimator: BaseEstimator,
        ode_solver: Type[OdeSolver] | str = ForwardEulerSolver,
        lse_solver: Callable[[np.ndarray, np.ndarray], np.ndarray] | None = None,
        num_timesteps: int | None = None,
        imag_part_tol: float = 1e-7,
        num_instability_tol: float = 1e-7,
    ) -> None:
        r"""
        Args:
            ansatz: Ansatz to be used for variational time evolution.
            initial_parameters: Initial parameter values for an ansatz.
            variational_principle: Variational Principle to be used.
            estimator: An estimator primitive used for calculating expectation values of
                TimeEvolutionProblem.aux_operators.
            ode_solver: ODE solver callable that implements a SciPy ``OdeSolver`` interface or a
                string indicating a valid method offered by SciPy.
            lse_solver: Linear system of equations solver callable. It accepts ``A`` and ``b`` to
                solve ``Ax=b`` and returns ``x``.
            num_timesteps: The number of timesteps to take. If None, it is
                automatically selected to achieve a timestep of approximately 0.01. Only
                relevant in case of the ``ForwardEulerSolver``.
            imag_part_tol: Allowed value of an imaginary part that can be neglected if no
                imaginary part is expected.
            num_instability_tol: The amount of negative value that is allowed to be
                rounded up to 0 for quantities that are expected to be
                non-negative.
        """
        super().__init__()
        self.ansatz = ansatz
        self.initial_parameters = initial_parameters
        self.variational_principle = variational_principle
        self.estimator = estimator
        self.num_timesteps = num_timesteps
        self.lse_solver = lse_solver
        self.ode_solver = ode_solver
        self.imag_part_tol = imag_part_tol
        self.num_instability_tol = num_instability_tol
        # OdeFunction abstraction kept for potential extensions - unclear at the moment;
        # currently hidden from the user
        self._ode_function_factory = OdeFunctionFactory()

    def evolve(self, evolution_problem: TimeEvolutionProblem) -> VarQTEResult:
        """Apply Variational Quantum Time Evolution to the given operator.

        Args:
            evolution_problem: Instance defining an evolution problem.
        Returns:
            Result of the evolution which includes a quantum circuit with bound parameters as an
            evolved state and, if provided, observables evaluated on the evolved state.

        Raises:
            ValueError: If ``initial_state`` is included in the ``evolution_problem``.
        """
        self._validate_aux_ops(evolution_problem)

        if evolution_problem.initial_state is not None:
            raise ValueError(
                "An initial_state was provided to the TimeEvolutionProblem but this is not "
                "supported by VarQTE. Please remove this state from the problem definition "
                "and set VarQTE.initial_parameters with the corresponding initial parameter "
                "values instead."
            )

        init_state_param_dict = self._create_init_state_param_dict(
            self.initial_parameters, self.ansatz.parameters
        )

        # unwrap PauliSumOp (in the future this will be deprecated)
        if isinstance(evolution_problem.hamiltonian, PauliSumOp):
            hamiltonian = (
                evolution_problem.hamiltonian.primitive * evolution_problem.hamiltonian.coeff
            )
        else:
            hamiltonian = evolution_problem.hamiltonian

        evolved_state, param_values, time_points = self._evolve(
            init_state_param_dict,
            hamiltonian,
            evolution_problem.time,
            evolution_problem.t_param,
        )

        observables = []
        if evolution_problem.aux_operators is not None:
            for values in param_values:
                # cannot batch evaluation because estimate_observables
                # only accepts single circuits
                evol_state = self.ansatz.assign_parameters(
                    dict(zip(init_state_param_dict.keys(), values))
                )
                observable = estimate_observables(
                    self.estimator,
                    evol_state,
                    evolution_problem.aux_operators,
                )
                observables.append(observable)

        # TODO: deprecate returning evaluated_aux_ops.
        #  As these are the observables for the last time step.
        evaluated_aux_ops = observables[-1] if len(observables) > 0 else None

        return VarQTEResult(
            evolved_state, evaluated_aux_ops, observables, time_points, param_values
        )

    def _evolve(
        self,
        init_state_param_dict: Mapping[Parameter, float],
        hamiltonian: BaseOperator,
        time: float,
        t_param: Parameter | None = None,
    ) -> tuple[QuantumCircuit | None, Sequence[Sequence[float]], Sequence[float]]:
        r"""
        Helper method for performing time evolution. Works both for imaginary and real case.

        Args:
            init_state_param_dict: Parameter dictionary with initial values for a given
                parametrized state/ansatz.
            hamiltonian: Operator used for Variational Quantum Time Evolution (VarQTE).
            time: Total time of evolution.
            t_param: Time parameter in case of a time-dependent Hamiltonian.

        Returns:
            Result of the evolution which is a quantum circuit with bound parameters as an
            evolved state.
        """

        init_state_parameters = list(init_state_param_dict.keys())
        init_state_parameter_values = list(init_state_param_dict.values())

        linear_solver = VarQTELinearSolver(
            self.variational_principle,
            hamiltonian,
            self.ansatz,
            init_state_parameters,
            t_param,
            self.lse_solver,
            self.imag_part_tol,
        )

        # Convert the operator that holds the Hamiltonian and ansatz into a NaturalGradient operator
        ode_function = self._ode_function_factory._build(
            linear_solver, init_state_param_dict, t_param
        )

        ode_solver = VarQTEOdeSolver(
            init_state_parameter_values, ode_function, self.ode_solver, self.num_timesteps
        )
        final_param_values, param_values, time_points = ode_solver.run(time)
        param_dict_from_ode = dict(zip(init_state_parameters, final_param_values))

        return self.ansatz.assign_parameters(param_dict_from_ode), param_values, time_points

    @staticmethod
    def _create_init_state_param_dict(
        param_values: Mapping[Parameter, float] | Sequence[float],
        init_state_parameters: Sequence[Parameter],
    ) -> Mapping[Parameter, float]:
        r"""
        If ``param_values`` is a dictionary, it looks for parameters present in an initial state
        (an ansatz) in a ``param_values``. Based on that, it creates a new dictionary containing
        only parameters present in an initial state and their respective values.
        If ``param_values`` is a list of values, it creates a new dictionary containing
        parameters present in an initial state and their respective values.

        Args:
            param_values: Dictionary which relates parameter values to the parameters or a list of
                values.
            init_state_parameters: Parameters present in a quantum state.

        Returns:
            Dictionary that maps parameters of an initial state to some values.

        Raises:
            ValueError: If the dictionary with parameter values provided does not include all
                parameters present in the initial state or if the list of values provided is not the
                same length as the list of parameters.
            TypeError: If an unsupported type of ``param_values`` provided.
        """
        if isinstance(param_values, Mapping):
            init_state_parameter_values: Sequence[float] = []
            for param in init_state_parameters:
                if param in param_values.keys():
                    init_state_parameter_values.append(param_values[param])
                else:
                    raise ValueError(
                        f"The dictionary with parameter values provided does not "
                        f"include all parameters present in the initial state."
                        f"Parameters present in the state: {init_state_parameters}, "
                        f"parameters in the dictionary: "
                        f"{list(param_values.keys())}."
                    )
        elif isinstance(param_values, (Sequence, np.ndarray)):
            if len(init_state_parameters) != len(param_values):
                raise ValueError(
                    f"Initial state has {len(init_state_parameters)} parameters and the"
                    f" list of values has {len(param_values)} elements. They should be"
                    f" equal in length."
                )
            init_state_parameter_values = param_values
        else:
            raise TypeError(f"Unsupported type of param_values provided: {type(param_values)}.")

        init_state_param_dict = dict(zip(init_state_parameters, init_state_parameter_values))
        return init_state_param_dict

    def _validate_aux_ops(self, evolution_problem: TimeEvolutionProblem) -> None:
        if evolution_problem.aux_operators is not None and self.estimator is None:
            raise ValueError(
                "aux_operators were provided for evaluations but no ``estimator`` was provided."
            )