# -*- coding: utf-8 -*-
"""
cyipopt: Python wrapper for the Ipopt optimization package, written in Cython.

Copyright (C) 2012-2015 Amit Aides
Copyright (C) 2015-2017 Matthias Kümmerer
Copyright (C) 2017-2024 cyipopt developers

License: EPL 2.0
"""

import sys

import numpy as np
try:
    import scipy
except ImportError:  # scipy is not installed
    SCIPY_INSTALLED = False
else:
    SCIPY_INSTALLED = True
    del scipy
    from scipy import optimize
    import scipy.sparse
    try:
        from scipy.optimize import OptimizeResult
    except ImportError:
        # in scipy 0.14 Result was renamed to OptimizeResult
        from scipy.optimize import Result
        OptimizeResult = Result
    try:
        # MemoizeJac has been made a private class, see
        # https://github.com/scipy/scipy/issues/17572
        from scipy.optimize._optimize import MemoizeJac
    except ImportError:
        from scipy.optimize.optimize import MemoizeJac
    try:
        from scipy.sparse import coo_array
    except ImportError:
        # coo_array was introduced with scipy 1.8
        from scipy.sparse import coo_matrix as coo_array

import cyipopt


class IpoptProblemWrapper(object):
    """Class used to map a scipy minimize definition to a cyipopt problem.

    Parameters
    ==========
    fun : callable
        The objective function to be minimized: ``fun(x, *args, **kwargs) ->
        float``.
    args : tuple, optional
        Extra arguments passed to the objective function and its derivatives
        (``fun``, ``jac``, ``hess``).
    kwargs : dictionary, optional
        Extra keyword arguments passed to the objective function and its
        derivatives (``fun``, ``jac``, ``hess``).
    jac : callable, optional
        The Jacobian (gradient) of the objective function:
        ``jac(x, *args, **kwargs) -> ndarray, shape(n, )``.
        If ``None``, SciPy's ``approx_fprime`` is used.
    hess : callable, optional
        If ``None``, the Hessian is computed using IPOPT's numerical methods.
        Explicitly defined Hessians are not yet supported for this class.
    hessp : callable, optional
        If ``None``, the Hessian is computed using IPOPT's numerical methods.
        Explicitly defined Hessians are not yet supported for this class.
    constraints : {Constraint, dict} or List of {Constraint, dict}, optional
        See :py:func:`scipy.optimize.minimize` for more information. Note that
        the jacobian of each constraint corresponds to the `'jac'` key and must
        be a callable function with signature ``jac(x) -> {ndarray,
        coo_array}``. If the constraint's value of `'jac'` is a boolean and
        True, the constraint function `fun` is expected to return a tuple
        `(con_val, con_jac)` consisting of the evaluated constraint `con_val`
        and the evaluated jacobian `con_jac`.
    eps : float, optional
        Step size used in finite difference approximations of the objective
        function gradient and constraint Jacobian.
    con_dims : array_like, optional
        Dimensions p_1, ..., p_m of the m constraint functions
        g_1, ..., g_m : R^n -> R^(p_i).
    sparse_jacs: array_like, optional
        If sparse_jacs[i] = True, the i-th constraint's jacobian is sparse.
        Otherwise, the i-th constraint jacobian is assumed to be dense.
    jac_nnz_row: array_like, optional
        The row indices of the nonzero elements in the stacked
        constraint jacobian matrix
    jac_nnz_col: array_like, optional
        The column indices of the nonzero elements in the stacked
        constraint jacobian matrix
    """

    def __init__(self,
                 fun,
                 args=(),
                 kwargs=None,
                 jac=None,
                 hess=None,
                 hessp=None,
                 constraints=(),
                 eps=1e-8,
                 con_dims=(),
                 sparse_jacs=(),
                 jac_nnz_row=(),
                 jac_nnz_col=()):
        if not SCIPY_INSTALLED:
            msg = 'Install SciPy to use the `IpoptProblemWrapper` class.'
            raise ImportError()
        self.obj_hess = None
        self.last_x = None

        # Input validation of user-provided arguments
        if fun is not None and not callable(fun):
            raise ValueError('`fun` must be callable.')
        if not isinstance(args, tuple):
            args = (args,)
        kwargs = dict() if kwargs is None else kwargs
        if not isinstance(kwargs, dict):
            raise ValueError('`kwargs` must be a dictionary.')
        if jac is not None and jac not in {True, False} and not callable(jac):
            raise ValueError('`jac` must be callable or boolean.')
        if hess is not None and not callable(hess):
            raise ValueError('`hess` must be callable.')
        if hessp is not None:
            raise NotImplementedError(
                '`hessp` is not yet supported by Ipopt.`')
        # TODO: add input validation for `constraints` when adding
        #  support for instances of new-style constraints (e.g.
        #  `NonlinearConstraint`) and sequences of constraints.

        if hess is not None:
            self.obj_hess = hess
        if not jac:
            def jac(x, *args, **kwargs):
                def wrapped_fun(x):
                    return fun(x, *args, **kwargs)
                return optimize.approx_fprime(x, wrapped_fun, eps)
        elif jac is True:
            fun = MemoizeJac(fun)
            jac = fun.derivative

        self.fun = fun
        self.jac = jac
        self.args = args
        self.kwargs = kwargs or {}
        self._constraint_funs = []
        self._constraint_jacs = []
        self._constraint_hessians = []
        self._constraint_dims = np.asarray(con_dims)
        self._constraint_args = []
        self._constraint_kwargs = []
        self._constraint_jac_is_sparse = sparse_jacs
        self._constraint_jacobian_structure = (jac_nnz_row, jac_nnz_col)
        if isinstance(constraints, dict):
            constraints = (constraints, )
        for con in constraints:
            con_fun = con['fun']
            con_jac = con.get('jac', None)
            con_args = con.get('args', [])
            con_hessian = con.get('hess', None)
            con_kwargs = con.get('kwargs', {})
            if con_jac is None:
                # beware of late binding!
                def con_jac(x, *args, con_fun=con_fun, **kwargs):
                    def wrapped(x):
                        return con_fun(x, *args, **kwargs)
                    return optimize.approx_fprime(x, wrapped, eps)
            elif con_jac is True:
                con_fun = MemoizeJac(con_fun)
                con_jac = con_fun.derivative
            elif not callable(con_jac):
                raise NotImplementedError('jac has to be bool or a function')
            if (self.obj_hess is not None
                    and con_hessian is None) or (self.obj_hess is None
                                                 and con_hessian is not None):
                msg = "hessian has to be provided for the objective and all constraints"
                raise NotImplementedError(msg)
            self._constraint_funs.append(con_fun)
            self._constraint_jacs.append(con_jac)
            self._constraint_hessians.append(con_hessian)
            self._constraint_args.append(con_args)
            self._constraint_kwargs.append(con_kwargs)
        # Set up evaluation counts
        self.nfev = 0
        self.njev = 0
        self.nit = 0

    def evaluate_fun_with_grad(self, x):
        """ For backwards compatibility. """
        return (self.objective(x), self.gradient(x, **self.kwargs))

    def objective(self, x):
        self.nfev += 1
        return self.fun(x, *self.args, **self.kwargs)

    # TODO : **kwargs is ignored, not sure why it is here.
    def gradient(self, x, **kwargs):
        self.njev += 1
        return self.jac(x, *self.args, **self.kwargs)  # .T

    def constraints(self, x):
        con_values = []
        for fun, args, kwargs in zip(self._constraint_funs,
                                     self._constraint_args,
                                     self._constraint_kwargs):
            con_values.append(fun(x, *args, **kwargs))
        return np.hstack(con_values)

    def jacobianstructure(self):
        return self._constraint_jacobian_structure

    def jacobian(self, x):
        # Convert all dense constraint jacobians to sparse ones.
        # The structure ( = row and column indices) is already known at this point,
        # so we only need to stack the evaluated jacobians
        jac_values = []
        for i, (jac, args, kwargs) in enumerate(zip(self._constraint_jacs,
                                                    self._constraint_args,
                                                    self._constraint_kwargs)):
            if self._constraint_jac_is_sparse[i]:
                jac_val = jac(x, *args, **kwargs)
                jac_values.append(jac_val.data)
            else:
                dense_jac_val = np.atleast_2d(jac(x, *args, **kwargs))
                jac_values.append(dense_jac_val.ravel())
        return np.hstack(jac_values)

    def hessian(self, x, lagrange, obj_factor):
        H = obj_factor * self.obj_hess(x, *self.args, **self.kwargs)  # type: ignore
        # split the lagrangian multipliers for each constraint hessian
        lagrs = np.split(lagrange, np.cumsum(self._constraint_dims[:-1]))
        for hessian, args, kwargs, lagr in zip(self._constraint_hessians,
                                               self._constraint_args,
                                               self._constraint_kwargs, lagrs):
            H += hessian(x, lagr, *args, **kwargs)
        return H[np.tril_indices(x.size)]

    def intermediate(self, alg_mod, iter_count, obj_value, inf_pr, inf_du, mu,
                     d_norm, regularization_size, alpha_du, alpha_pr,
                     ls_trials):

        self.nit = iter_count


def get_bounds(bounds):
    if bounds is None:
        return None, None
    else:
        lb = [b[0] for b in bounds]
        ub = [b[1] for b in bounds]
        return lb, ub


def _get_sparse_jacobian_structure(constraints, x0):
    con_jac_is_sparse = []
    jacobians = []
    x0 = np.asarray(x0)
    if isinstance(constraints, dict):
        constraints = (constraints, )
    if len(constraints) == 0:
        return [], [], []
    for con in constraints:
        con_jac = con.get('jac', False)
        if con_jac:
            if isinstance(con_jac, bool):
                _, jac_val = con['fun'](x0, *con.get('args', []),
                                        **con.get('kwargs', {}))
            else:
                jac_val = con_jac(x0, *con.get('args', []),
                                  **con.get('kwargs', {}))
            # check if dense or sparse
            if isinstance(jac_val, coo_array):
                jacobians.append(jac_val)
                con_jac_is_sparse.append(True)
            else:
                # Creating the coo_array from jac_val would yield to
                # wrong dimensions if some values in jac_val are zero,
                # so we assume all values in jac_val are nonzero
                jacobians.append(coo_array(np.ones_like(np.atleast_2d(jac_val))))
                con_jac_is_sparse.append(False)
        else:
            # we approximate this jacobian later (=dense)
            con_val = np.atleast_1d(con['fun'](x0, *con.get('args', []),
                                               **con.get('kwargs', {})))
            jacobians.append(coo_array(np.ones((con_val.size, x0.size))))
            con_jac_is_sparse.append(False)
    J = scipy.sparse.vstack(jacobians)
    return con_jac_is_sparse, J.row, J.col


def get_constraint_dimensions(constraints, x0):
    con_dims = []
    if isinstance(constraints, dict):
        constraints = (constraints, )
    for con in constraints:
        if con.get('jac', False) is True:
            m = len(np.atleast_1d(con['fun'](x0, *con.get('args', []),
                                             **con.get('kwargs', {}))[0]))
        else:
            m = len(np.atleast_1d(con['fun'](x0, *con.get('args', []),
                                             **con.get('kwargs', {}))))
        con_dims.append(m)
    return np.array(con_dims)


def get_constraint_bounds(constraints, x0, INF=1e19):
    cl = []
    cu = []
    if isinstance(constraints, dict):
        constraints = (constraints, )
    for con in constraints:
        if con.get('jac', False) is True:
            m = len(np.atleast_1d(con['fun'](x0, *con.get('args', []),
                                             **con.get('kwargs', {}))[0]))
        else:
            m = len(np.atleast_1d(con['fun'](x0, *con.get('args', []),
                                             **con.get('kwargs', {}))))
        cl.extend(np.zeros(m))
        if con['type'] == 'eq':
            cu.extend(np.zeros(m))
        elif con['type'] == 'ineq':
            cu.extend(INF * np.ones(m))
        else:
            raise ValueError(con['type'])
    cl = np.array(cl)
    cu = np.array(cu)

    return cl, cu


def replace_option(options, oldname, newname):
    if oldname in options:
        if newname not in options:
            options[newname] = options.pop(oldname)


def convert_to_bytes(options):
    if sys.version_info >= (3, 0):
        for key in list(options.keys()):
            try:
                if bytes(key, 'utf-8') != key:
                    options[bytes(key, 'utf-8')] = options[key]
                    options.pop(key)
            except TypeError:
                pass


def _wrap_fun(fun, kwargs):
    if callable(fun) and kwargs:
        def new_fun(x, *args):
            return fun(x, *args, **kwargs)
    else:
        new_fun = fun
    return new_fun

def _wrap_funs(fun, jac, hess, hessp, constraints, kwargs):
    wrapped_fun = _wrap_fun(fun, kwargs)
    wrapped_jac = _wrap_fun(jac, kwargs)
    wrapped_hess = _wrap_fun(hess, kwargs)
    wrapped_hessp = _wrap_fun(hessp, kwargs)
    if isinstance(constraints, dict):
        constraints = (constraints,)
    wrapped_constraints = []
    for constraint in constraints:
        constraint = constraint.copy()
        ckwargs = constraint.pop('kwargs', {})
        constraint['fun'] = _wrap_fun(constraint.get('fun', None), ckwargs)
        constraint['jac'] = _wrap_fun(constraint.get('jac', None), ckwargs)
        wrapped_constraints.append(constraint)
    return (wrapped_fun, wrapped_jac, wrapped_hess, wrapped_hessp,
            wrapped_constraints)



def minimize_ipopt(fun,
                   x0,
                   args=(),
                   kwargs=None,
                   method=None,
                   jac=None,
                   hess=None,
                   hessp=None,
                   bounds=None,
                   constraints=(),
                   tol=None,
                   callback=None,
                   options=None):
    """
    Minimization using Ipopt with an interface like
    :py:func:`scipy.optimize.minimize`.

    Differences compared to :py:func:`scipy.optimize.minimize` include:

    - A different default `method`: when `method` is not provided, Ipopt is
      used to solve the problem.
    - Support for parameter `kwargs`: additional keyword arguments to be
      passed to the objective function, constraints, and their derivatives.
    - Lack of support for `callback` and `hessp` with the default `method`.

    This function can be used to solve general nonlinear programming problems
    of the form:

    .. math::

       \min_ {x \in R^n} f(x)

    subject to

    .. math::

       g_L \leq g(x) \leq g_U

       x_L \leq  x  \leq x_U

    where :math:`x` are the optimization variables, :math:`f(x)` is the
    objective function, :math:`g(x)` are the general nonlinear constraints,
    and :math:`x_L` and :math:`x_U` are the upper and lower bounds
    (respectively) on the decision variables. The constraints, :math:`g(x)`,
    have lower and upper bounds :math:`g_L` and :math:`g_U`. Note that equality
    constraints can be specified by setting :math:`g^i_L = g^i_U`.

    Parameters
    ----------
    fun : callable
        The objective function to be minimized: ``fun(x, *args, **kwargs) ->
        float``.
    x0 : array-like, shape(n, )
        Initial guess. Array of real elements of shape (n,),
        where ``n`` is the number of independent variables.
    args : tuple, optional
        Extra arguments passed to the objective function and its
        derivatives (``fun``, ``jac``, and ``hess``).
    kwargs : dictionary, optional
        Extra keyword arguments passed to the objective function and its
        derivatives (``fun``, ``jac``, ``hess``).
    method : str, optional
        If unspecified (default), Ipopt is used.
        :py:func:`scipy.optimize.minimize` methods can also be used.
    jac : callable, optional
        The Jacobian (gradient) of the objective function:
        ``jac(x, *args, **kwargs) -> ndarray, shape(n, )``.
        If ``None``, SciPy's ``approx_fprime`` is used.
    hess : callable, optional
        The Hessian of the objective function:
        ``hess(x) -> ndarray, shape(n, )``.
        If ``None``, the Hessian is computed using IPOPT's numerical methods.
    hessp : callable, optional
        If `method` is one of the SciPy methods, this is a callable that
        produces the inner product of the Hessian and a vector. Otherwise, an
        error will be raised if a value other than ``None`` is provided.
    bounds :  sequence of shape(n, ) or :py:class:`scipy.optimize.Bounds`, optional
        Simple bounds on decision variables. There are two ways to specify the
        bounds:

            1. Instance of :py:class:`scipy.optimize.Bounds` class.
            2. Sequence of ``(min, max)`` pairs for each element in `x`. Use
               ``None`` to specify an infinite bound (i.e., no bound).

    constraints : {Constraint, dict}, optional
        Linear or nonlinear constraint specified by a dictionary,
        :py:class:`scipy.optimize.LinearConstraint`, or
        :py:class:`scipy.optimize.NonlinearConstraint`.
        See :py:func:`scipy.optimize.minimize` for more information. Note that
        the Jacobian of each constraint corresponds to the ``'jac'`` key and
        must be a callable function with signature ``jac(x) -> {ndarray,
        coo_array}``. If the constraint's value of ``'jac'`` is ``True``, the
        constraint function ``fun`` must return a tuple ``(con_val, con_jac)``
        consisting of the evaluated constraint ``con_val`` and the evaluated
        Jacobian ``con_jac``.
    tol : float, optional (default=1e-8)
        The desired relative convergence tolerance, passed as an option to
        Ipopt. See [1]_ for details.
    options : dict, optional
        A dictionary of solver options.

        When `method` is unspecified (default: Ipopt), the options
        ``disp`` and ``maxiter`` are automatically mapped to their Ipopt
        equivalents ``print_level`` and ``max_iter``, and ``eps`` is used to
        control the step size of finite difference gradient and constraint
        Jacobian approximations. All other options are passed directly
        to Ipopt. See [1]_ for details.

        For other values of `method`, `options` is passed to the SciPy solver.
        See [2]_ for details.
    callback : callable, optional
        This argument is ignored by the default `method` (Ipopt).
        If `method` is one of the SciPy methods, this is a callable that is
        called once per iteration. See [2]_ for details.

    References
    ----------
    .. [1] COIN-OR Project. "Ipopt: Ipopt Options".
           https://coin-or.github.io/Ipopt/OPTIONS.html
    .. [2] The SciPy Developers. "scipy.optimize.minimize".
           https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html

    Examples
    --------
    Consider the problem of minimizing the Rosenbrock function. The Rosenbrock
    function and its derivatives are implemented in
    :py:func:`scipy.optimize.rosen`, :py:func:`scipy.optimize.rosen_der`, and
    :py:func:`scipy.optimize.rosen_hess`.

    >>> from cyipopt import minimize_ipopt
    >>> from scipy.optimize import rosen, rosen_der
    >>> x0 = [1.3, 0.7, 0.8, 1.9, 1.2]  # initial guess

    If we provide the objective function but no derivatives, Ipopt finds the
    correct minimizer (``[1, 1, 1, 1, 1]``) with a minimum objective value of
    0. However, it does not report success, and it requires many iterations
    and function evaluations before termination. This is because SciPy's
    ``approx_fprime`` requires many objective function evaluations to
    approximate the gradient, and still the approximation is not very accurate,
    delaying convergence.

    >>> res = minimize_ipopt(rosen, x0, jac=rosen_der)
    >>> res.success
    False
    >>> res.x
    array([1., 1., 1., 1., 1.])
    >>> res.nit, res.nfev, res.njev
    (46, 528, 48)

    To improve performance, provide the gradient using the `jac` keyword.
    In this case, Ipopt recognizes its own success, and requires fewer function
    evaluations to do so.

    >>> res = minimize_ipopt(rosen, x0, jac=rosen_der)
    >>> res.success
    True
    >>> res.nit, res.nfev, res.njev
    (37, 200, 39)

    For best results, provide the Hessian, too.

    >>> res = minimize_ipopt(rosen, x0, jac=rosen_der, hess=rosen_hess)
    >>> res.success
    True
    >>> res.nit, res.nfev, res.njev
    (17, 29, 19)
    """
    if not SCIPY_INSTALLED:
        msg = 'Install SciPy to use the `minimize_ipopt` function.'
        raise ImportError(msg)

    res = _minimize_ipopt_iv(fun, x0, args, kwargs, method, jac, hess, hessp,
                             bounds, constraints, tol, callback, options)
    (fun, x0, args, kwargs, method, jac, hess, hessp,
     bounds, constraints, tol, callback, options) = res

    if method is not None:
        funs = _wrap_funs(fun, jac, hess, hessp, constraints, kwargs)
        fun, jac, hess, hessp, constraints = funs
        bounds = optimize.Bounds(*bounds)
        res = optimize.minimize(fun, x0, args, method, jac, hess, hessp,
                                bounds, constraints, tol, callback, options)
        return res

    _x0 = np.atleast_1d(x0)

    lb, ub = bounds
    cl, cu = get_constraint_bounds(constraints, _x0)
    con_dims = get_constraint_dimensions(constraints, _x0)
    sparse_jacs, jac_nnz_row, jac_nnz_col = _get_sparse_jacobian_structure(
        constraints, x0)

    if options is None:
        options = {}
    eps = options.pop('eps', 1e-8)

    problem = IpoptProblemWrapper(fun,
                                  args=args,
                                  kwargs=kwargs,
                                  jac=jac,
                                  hess=hess,
                                  hessp=hessp,
                                  constraints=constraints,
                                  eps=eps,
                                  con_dims=con_dims,
                                  sparse_jacs=sparse_jacs,
                                  jac_nnz_row=jac_nnz_row,
                                  jac_nnz_col=jac_nnz_col)

    nlp = cyipopt.Problem(n=len(x0),
                          m=len(cl),
                          problem_obj=problem,
                          lb=lb,
                          ub=ub,
                          cl=cl,
                          cu=cu)

    # python3 compatibility
    convert_to_bytes(options)

    # Rename some default scipy options
    replace_option(options, b'disp', b'print_level')
    replace_option(options, b'maxiter', b'max_iter')
    options[b'print_level'] = int(options.get(b'print_level', 0))

    if b'tol' not in options:
        options[b'tol'] = tol or 1e-8
    if b'mu_strategy' not in options:
        options[b'mu_strategy'] = b'adaptive'
    if b'hessian_approximation' not in options:
        if hess is None and hessp is None:
            options[b'hessian_approximation'] = b'limited-memory'
    for option, value in options.items():
        try:
            nlp.add_option(option, value)
        except TypeError as e:
            msg = 'Invalid option for IPOPT: {0}: {1} (Original message: "{2}")'
            raise TypeError(msg.format(option, value, e))

    x, info = nlp.solve(x0)

    return OptimizeResult(x=x,
                          success=info['status'] == 0,
                          status=info['status'],
                          message=info['status_msg'],
                          fun=info['obj_val'],
                          info=info,
                          nfev=problem.nfev,
                          njev=problem.njev,
                          nit=problem.nit)


def _minimize_ipopt_iv(fun, x0, args, kwargs, method, jac, hess, hessp,
                       bounds, constraints, tol, callback, options):
    # basic input validation for minimize_ipopt that is not included in
    # IpoptProblemWrapper
    x0 = np.atleast_1d(x0)
    if not np.issubdtype(x0.dtype, np.number):
        raise ValueError('`x0` must be a numeric array.')

    # `method` does not need input validation. If `method is not None`, it is
    # passed to `scipy.optimize.minimize`, which raises a readable error if
    # the value isn't recognized.

    # Handle bounds that are either sequences (of sequences) or instances of
    # `optimize.Bounds`.
    if bounds is None:
        bounds = [-np.inf, np.inf]

    if isinstance(bounds, optimize.Bounds):
        lb, ub = bounds.lb, bounds.ub
    else:
        bounds = np.atleast_2d(bounds)
        if bounds.shape[1] != 2:
            raise ValueError("`bounds` must specify both lower and upper "
                             "limits for each decision variable.")
        lb, ub = bounds.T

    try:
        lb, ub, x0 = np.broadcast_arrays(lb, ub, x0)
    except ValueError:
        raise ValueError("The number of lower bounds, upper bounds, and "
                         "decision variables must be equal or broadcastable.")

    try:
        lb = lb.astype(np.float64)
        ub = ub.astype(np.float64)
    except ValueError:
        raise ValueError("The bounds must be numeric.")

    # Nones turn into NaNs above. Previously, NaNs caused Ipopt to hang, so
    # I'm not concerned about turning them into infs.
    lb[np.isnan(lb)] = -np.inf
    ub[np.isnan(ub)] = np.inf

    bounds = lb, ub

    constraints = optimize._minimize.standardize_constraints(constraints, x0,
                                                             'old')

    if method is None and callback is not None:
        raise NotImplementedError('`callback` is not yet supported by Ipopt.`')

    if tol is not None:
        tol = np.asarray(tol)[()]
        if tol.ndim != 0 or not np.issubdtype(tol.dtype, np.number) or tol <= 0:
            raise ValueError('`tol` must be a positive scalar.')
        else:  # tol should be a float, not an array
            tol = float(tol)

    options = dict() if options is None else options
    if not isinstance(options, dict):
        raise ValueError('`options` must be a dictionary.')

    return (fun, x0, args, kwargs, method, jac, hess, hessp,
            bounds, constraints, tol, callback, options)
