"""Adaptive Memory Programming for Global Optimization (AMPGO).

added to lmfit by Renee Otten (2018)

based on the Python implementation of Andrea Gavana
(see: http://infinity77.net/global_optimization/)

Implementation details can be found in this paper:
    http://leeds-faculty.colorado.edu/glover/fred%20pubs/416%20-%20AMP%20(TS)%20for%20Constrained%20Global%20Opt%20w%20Lasdon%20et%20al%20.pdf
"""

from __future__ import print_function

import numpy as np
from scipy.optimize import minimize

SCIPY_LOCAL_SOLVERS = ['Nelder-Mead', 'Powell', 'L-BFGS-B', 'TNC', 'SLSQP']


def ampgo(objfun, x0, args=(), local='L-BFGS-B', local_opts=None, bounds=None,
          maxfunevals=None, totaliter=20, maxiter=5, glbtol=1e-5, eps1=0.02,
          eps2=0.1, tabulistsize=5, tabustrategy='farthest', disp=False):
    """Find the global minimum of a multivariate function using the AMPGO
    (Adaptive Memory Programming for Global Optimization) algorithm.

    Parameters
    ----------
    objfun: callable
        Objective function to be minimized. The function must have the signature:
        objfun(params, *args, **kws)
    x0: numpy.ndarray
         Initial guesses for parameter values.
    args: tuple, optional
         Additional arguments passed to `objfun`.
    local: str, optional
        Name of the local minimization method. Valid options are:

        - `'L-BFGS-B'` (default)
        - `Nelder-Mead'`
        - `'Powell'`
        - `'TNC'`
        - `'SLSQP'`

    local_opts: dict, optional
        Options to pass to the local minimizer.
    bounds: sequence, optional
        List of tuples specifying the lower and upper bound for each
        independent variable [(`xl0`, `xu0`), (`xl1`, `xu1`), ...].
    maxfunevals: int, optional
        Maximum number of function evaluations. If None, the optimization will
        stop after `totaliter` number of iterations.
    totaliter: int, optional
        Maximum number of global iterations.
    maxiter: int, optional
        Maximum number of `Tabu Tunneling` iterations during each global
        iteration.
    glbtol: float, optional
        Tolerance whether or not to accept a solution after a tunneling phase.
    eps1: float, optional
        Constant used to define an aspiration value for the objective function
        during the Tunneling phase.
    eps2: float, optional
        Perturbation factor used to move away from the latest local minimum
        at the start of a Tunneling phase.
    tabulistsize: int, optional
        Size of the (circular) tabu search list.
    tabustrategy: str, optional
        Strategy to use when the size of the tabu list exceeds `tabulistsize`.
        It can be 'oldest' to drop the oldest point from the tabu list or
        'farthest' to drop the element farthest from the last local minimum
        found.
    disp: bool, optional
        Set to True to print convergence messages.

    Returns
    -------
    tuple:
        A tuple of 5 elements, in the following order:
         1. **best_x** (`array_like`): the estimated position of the global
             minimum.
         2. **best_f** (`float`): the value of `objfun` at the minimum.
         3. **evaluations** (`integer`): the number of function evaluations.
         4. **msg** (`string`): a message describes the cause of the termination.
         5. **tunnel_info** (`tuple`): a tuple containing the total number of
             Tunneling phases performed and the successful ones.

    Notes
    -----
    The detailed implementation of AMPGO is described in the paper
    "Adaptive Memory Programming for Constrained Global Optimization" located
    here:

    http://leeds-faculty.colorado.edu/glover/fred%20pubs/416%20-%20AMP%20(TS)%20for%20Constrained%20Global%20Opt%20w%20Lasdon%20et%20al%20.pdf

    """
    if local not in SCIPY_LOCAL_SOLVERS:
        raise Exception('Invalid local solver selected: {}'.format(local))

    x0 = np.atleast_1d(x0)
    n = len(x0)

    if bounds is None:
        bounds = [(None, None)] * n
    if len(bounds) != n:
        raise ValueError('length of x0 != length of bounds')

    bounds = [b if b is not None else (None, None) for b in bounds]
    _bounds = [(-np.inf if l is None else l, np.inf if u is None else u)
               for l, u in bounds]
    low, up = np.array(_bounds).T

    if maxfunevals is None:
        maxfunevals = np.inf

    if tabulistsize < 1:
        raise Exception('Invalid tabulistsize specified: {:d}. It should be '
                        'an integer greater than zero.'.format(tabulistsize))
    if tabustrategy not in ['oldest', 'farthest']:
        raise Exception('Invalid tabustrategy specified: {:s}. It must be one '
                        'of "oldest" or "farthest".'.format(tabustrategy))

    tabulist = []
    best_f = np.inf
    best_x = x0

    global_iter = 0
    all_tunnel = success_tunnel = 0
    evaluations = 0

    local_tol = min(1e-8, glbtol)

    while 1:

        # minimization to find local minimum, either from initial values or
        # after a successful tunneling loop
        if disp:
            print('\n{0}\nStarting MINIMIZATION Phase {1:d}\n{0}'
                  .format('='*72, global_iter+1))

        options = {'maxiter': max(1, maxfunevals), 'disp': disp}
        if local_opts is not None:
            options.update(local_opts)
        res = minimize(objfun, x0, args=args, method=local, bounds=bounds,
                       tol=local_tol, options=options)
        xf, yf, num_fun = res['x'], res['fun'], res['nfev']
        if isinstance(yf, np.ndarray):
            yf = yf[0]

        maxfunevals -= num_fun
        evaluations += num_fun

        if yf < best_f:
            best_f = yf
            best_x = xf

        if disp:
            print('\n\n ==> Reached local minimum: {:.5g}\n'.format(yf))

        if maxfunevals <= 0:
            if disp:
                print('='*72)
            return (best_x, best_f, evaluations,
                    'Maximum number of function evaluations exceeded',
                    (all_tunnel, success_tunnel))

        # if needed, drop a value from the tabu tunneling list and add the
        # current solution
        tabulist = drop_tabu_points(xf, tabulist, tabulistsize, tabustrategy)
        tabulist.append(xf)

        i = improve = 0

        while i < maxiter and improve == 0:

            if disp:
                print('{0}\nStarting TUNNELING Phase ({1:d}-{2:d})\n{0}'
                      .format('='*72, global_iter+1, i+1))

            all_tunnel += 1

            # generate a new starting point away from the current solution
            r = np.random.uniform(-1.0, 1.0, size=(n, ))
            beta = eps2*np.linalg.norm(xf) / np.linalg.norm(r)

            if np.abs(beta) < 1e-8:
                beta = eps2

            x0 = xf + beta*r

            # make sure that the new starting point is within bounds
            x0 = np.where(x0 < low, low, x0)
            x0 = np.where(x0 > up, up, x0)

            # aspired value of the objective function for the tunneling loop
            aspiration = best_f - eps1*(1.0 + np.abs(best_f))

            tunnel_args = tuple([objfun, aspiration, tabulist] + list(args))

            options = {'maxiter': max(1, maxfunevals), 'disp': disp}
            if local_opts is not None:
                options.update(local_opts)

            res = minimize(tunnel, x0, args=tunnel_args, method=local,
                           bounds=bounds, tol=local_tol, options=options)
            xf, yf, num_fun = res['x'], res['fun'], res['nfev']
            if isinstance(yf, np.ndarray):
                yf = yf[0]

            maxfunevals -= num_fun
            evaluations += num_fun

            yf = inverse_tunnel(xf, yf, aspiration, tabulist)

            if yf <= best_f + glbtol:
                oldf = best_f
                best_f = yf
                best_x = xf
                improve = 1
                success_tunnel += 1

                if disp:
                    print('\n\n ==> Successful tunnelling phase. Reached new '
                          'local minimum: {:.5g} < {:.5g}\n'.format(yf, oldf))

            i += 1

            if maxfunevals <= 0:
                return (best_x, best_f, evaluations,
                        'Maximum number of function evaluations exceeded',
                        (all_tunnel, success_tunnel))

            tabulist = drop_tabu_points(xf, tabulist, tabulistsize, tabustrategy)
            tabulist.append(xf)

        if disp:
            print('='*72)

        global_iter += 1
        x0 = xf.copy()

        if global_iter >= totaliter:
            return (best_x, best_f, evaluations,
                    'Maximum number of global iterations exceeded',
                    (all_tunnel, success_tunnel))


def drop_tabu_points(xf, tabulist, tabulistsize, tabustrategy):
    """Drop a point from the tabu search list."""
    if len(tabulist) < tabulistsize:
        return tabulist

    if tabustrategy == 'oldest':
        tabulist.pop(0)
    else:
        distance = np.sqrt(np.sum((tabulist - xf)**2, axis=1))
        index = np.argmax(distance)
        tabulist.pop(index)

    return tabulist


def tunnel(x0, *args):
    """Tunneling objective function.

    This function has a global minimum of zero at any feasible point where
    `f(x) = aspiration`, and minimizing this expression tends to move away
    from all points in `tabulist`.

    """
    objfun, aspiration, tabulist = args[0:3]

    fun_args = ()
    if len(args) > 3:
        fun_args = tuple(args[3:])

    numerator = (objfun(x0, *fun_args) - aspiration)**2
    denominator = 1.0

    for tabu in tabulist:
        denominator = denominator * np.sqrt(np.sum((x0 - tabu)**2))

    ytf = numerator/denominator

    return ytf


def inverse_tunnel(xtf, ytf, aspiration, tabulist):
    """Calculate the function value after a tunneling phase step."""
    denominator = 1.0

    for tabu in tabulist:
        denominator = denominator * np.sqrt(np.sum((xtf - tabu)**2))

    numerator = ytf*denominator
    yf = aspiration + np.sqrt(numerator)

    return yf
