# -*- coding: utf-8 -*-
import os.path

import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime
import multiprocessing
import string
from rich.console import Console
try:
    from rich.console import Group
except:
    from rich.console import RenderGroup as Group    
from rich.progress import Progress, BarColumn, TimeRemainingColumn, TimeElapsedColumn
from rich.live import Live
from rich.table import Table
import indago


class Optimizer:
    """Base class for all optimization methods.

    Parameters
    ----------
    dimensions : int
        Number of dimensions of the search space i.e. number of optimization variables.
    evaluation_function : callable
        A function (or a callable class) which takes a ndarray as argument and returns fitness value (float), 
        or in case of multi-objective and/or constrained optimization returns a tuple containing objectives' and
        constraints' values. Optionally, it can take a keyword argument **s** (str) in order to handle a unique string
        generated by the optimizer.
    number_of_processes : int or str
        Number of processes in a multiprocessing pool used for parallel evaluation of **evaluation_function**.
        If ``'max'`` all available processor(s) cores will be used. Default: ``1``.
    objectives : int
        Number of objectives. Default: ``1``.
    objective_weights : list of float
        Weights for objectives used in weighted sum multi-objective optimization. The weighted sum allows combining
        multiple objectives into a single value (fitness) function employed in the optimization. In order to properly
        utilize the weighted sum method, all objectives should be normalized to values of the same order of magnitude.
        This authorizes using several objectives and appointing their importance or priority in the optimization by
        setting the objective weights. By default, all objectives are of same priority, and their values are set to 1.
    objective_labels : list of str
        Names of objectives. Used in outputs such as monitoring, logs, plots etc. Default objective labels are
        ``['obj_0', 'obj_1', ... etc]``.
    constraints : int
        Number of constraints. Default: ``0``.
    constraints_labels : list of str
        Names of constraints. Used in outputs such as monitoring, logs, plots etc. By default, constraint labels are
        set to ``['cnstr_0', 'cnstr_1', ... etc]``.
    max_iterations : int
        Maximum allowed number of iterations of the used optimization method. Default: ``None``.
    max_evaluations : int
        Maximum allowed number of evaluations to be used in the optimization process. Default: ``None``.
        If **max_iterations**, **max_evaluations**, **max_stalled_iterations**, and **max_stalled_evaluations** 
        are all ``None``, defaults to: 50 * **dimensions** ^2.
    max_stalled_iterations : int
        Maximum allowed number of method iterations without any progress. Default: ``None``.
    max_stalled_evaluations : int
        Maximum allowed number of evaluations without any progress. Default: ``None``.
    target_fitness : float
        Target fitness value at which the optimization will be stopped. Default: ``None``.
    lb : ndarray or list of float or float
        Lower bounds. If float it will be expanded to ndarray of float of size **dimensions**. Default: ``None``.
    ub : ndarray or list of float or float
        Upper bounds. If float it will be expanded to ndarray of float of size **dimensions**. Default: ``None``.     
    best : CandidateState
        The best candidate state insofar encountered in the optimization process.
    X0 : ndarray or int
        Starting point(s) for the optimization. 1d ndarray, or 2d ndarray with each row representing one design vector.
        If int, represents the number of random candidates generated at the start of optimization. 
        Each method embeds these candidates, fully or partially, in initial population, swarm or starting points. 
        Initial candidates are selected in order of their fitness. 
    _cs0 : ndarray
        A private array of CandidateState instances populated and evaluated according to given Optimizer.X0 parameter.
        The array is adopted in each method for establishing initial population, swarm or starting point.
    _evaluated_candidates : ndarray of CandidateState
        A private array used as a buffer of evaluated candidates in current iteration. Enables forwarding a list of all
        evaluated candidates in an interation to be forwarded to post_iteration_processing function even if multiple
        collective evaluations are utilized in a single iteration of a method.
    history : dictionary
        A dictionary of appropriately formatted arrays that keeps record of optimization convergence. Using dictionary
        keys one can access the information for the best candidate in any iteration:
        ``history['eval']`` for number of evaluations (shape ``[iterations, 1]``),
        ``history['X'`` for optimization vector (shape: ``[iterations, dimensions]``),
        ``history['O']`` for objectives (shape ``[iterations, objectives]``),
        ``history['C']`` for constraints (shape ``[iterations, constraints]``), and
        ``history['f']`` for fitness (shape ``[iterations, 1]``. The same data rearranged in a single matrix is stored
        in ``convergence_log_file``.
    monitoring : str or None
        Adjust the monitoring style and level of output produced during the optimization. ``'none'`` or
        ``None`` suppresses all output. For displaying a line-per-iteration output to the terminal use
        ``'basic'`` which can display a colored output if supported by your terminal. For displaying a
        progress bar and the dynamically updated best solution use ``'dashboard'``. Default: ``'none'``.
    _progress_log : None or callable
        A priovate handle for progress log function. It is set to ``__progress_log_basic`` when ``monitoring='basic'``
        is used.
    convergence_log_file : None or str
        A name of the file where convergence log is stored. The convergence file is suitably formatted allowing reading
        it using ``numpy.loadtxt``, and it includes rows for: number of iterations, number of evaluations, optimization
        variables, objectives, constraints and fitness. Default: ``None`` (no convergence log is stored).
    forward_unique_str : bool
        If ``True``, unique string forwarding is used. The strings are generated by Indago and are then 
        passed on to **evaluation_function** as an additional argument. Default: ``False``.
    _unique_str_list : list
        A private list for tracking used unique strings.
    post_iteration_processing : callable
        A handle for callable object (e.g. function) that is being evoked after each iteration of the method. The
        function is expected to process following arguments ``it`` (``int``, current iteration), ``candidates`` (an
        array of CandidateStates evaluated in the current iteration) and ``best`` (best CandidateState found until
        current iteration).
    safe_evaluation : bool
        If ``True``, in case of failed evaluations exceptions thrown by the **evaluation_function** 
        are caught and ``np.nan`` is returned instead. Default: ``False``.
    eval_fail_count : int
        Used for counting failed evaluations.
    eval_fail_behavior : str
        Controlling behavior in case of **evaluation_function** returning ``np.nan``. 
        If ``'abort'`` the optimization is stopped at the first event of evaluation function returning ``np.nan``.
        If ``'ignore'`` the optimizer will ignore any ``np.nan`` values returned by the evaluation function (not all
        Indago methods support this). If ``'retry'`` the optimizer will try to resolve the issue by repeatedly receding
        a failed design vector a small step towards the best solution thus far. Default: ``'abort'``.
    eval_retry_attempts : int
        Number of retry attempts in case of failed evaluation. Used only if ``eval_fail_behavior='retry'``.
        Default: ``10``.
    eval_retry_recede : float
        Relative distance in the range ``[0, 1]`` for which the failed evaluation vector will be translated in the
        direction of the best solution thus far. Used only if ``eval_fail_behavior='retry'``. Default: ``0.01``.
    it : int
        Used for counting iterations.
    eval : int
        Used for counting evaluations.
    _stalled_it : int
        Used for counting stalled iterations.
    _stalled_eval : int
        Used for counting stalled evaluations.
    params : dict
        Method parameters, in the form of parameter name (dict key as str), parameter value (dict value).
        Defaults specific to the optimization method used.
    
    Returns
    -------
    Optimizer
        Optimizer instance
    """

    def __init__(self):

        self.dimensions = None
        self.evaluation_function = None
        self.number_of_processes = 1
        
        self.objectives = 1
        self.objective_weights = None
        self.objective_labels = None
        self.constraints = 0
        self.constraint_labels = None
        
        self.max_iterations = None        
        self.max_evaluations = None        
        self.max_stalled_iterations = None
        self.max_stalled_evaluations = None       
        self.target_fitness = None
        
        self.lb = None
        self.ub = None
        self.best = None
        self.X0 = None
        self._cs0 = None
        self._evaluated_candidates = np.array([], dtype=CandidateState)

        self.history = None
        self.monitoring = None
        self._progress_log = None
        self.convergence_log_file = None

        self.forward_unique_str = False
        self._unique_str_list = []
        self.post_iteration_processing = None
        
        self.safe_evaluation = None
        self.eval_fail_count = 0
        self.eval_fail_behavior = None
        self.eval_retry_attempts = None
        self.eval_retry_recede = None
        
        self.it = 0
        self.eval = 0
        self._stalled_it = None
        self._stalled_eval = None

        self.params = {}
        
    def _progress_factor(self):
        """Private method for calculating progress factor ranging from zero to one,
        based on iterations or evaluations (whichever is running out faster).
        Used in optimization methods which adapt their behavior during the optimization process
        based on the time passed.
        
        Returns
        -------
        progress_factor : float
            A number between zero and one, representing the temporal progress of the optimization process.
        """
        
        if self.it <= 1:
            assert self.max_iterations or self.max_evaluations, \
                'optimizer.max_iteration or optimizer.max_evaluations should be provided for this method/variant'
        
        prog = []
        if self.max_iterations:
            prog.append(self.it / self.max_iterations)
        if self.max_evaluations:
            prog.append(self.eval / self.max_evaluations)
        return max(prog)	
    
    def _evaluation_function_safe(self, X, s=None):
        """Private method for wrapping evaluation function in try-except.

        Returns
        -------
        fitness : float
            Fitness value as computed by evaluation function, 
            or ``np.nan`` if failed to compute evaluation function.
        """
        try:
            if s is None:
                return self.evaluation_function(X)
            else:
                return self.evaluation_function(X, s)
        except:
            return np.nan
    
    def _init_optimizer(self):
        """Private method for Optimizer initialization which checks the types and values 
        of Optimizer member parameters. Automatically sets to default values the 
        optional Optimizer-level parameters if they are not provided.

        Returns
        -------
        None
            Nothing
        """
        
        if self.dimensions is not None:
            assert isinstance(self.dimensions, int) and self.dimensions > 0, \
                "optimizer.dimensions should be positive integer"
        else:
            self.dimensions = max(np.size(self.lb), np.size(self.ub))
            assert self.dimensions > 1, \
                "optimizer.lb and optimizer.ub both of size 1, missing optimizer.dimensions"
        
        # expand scalar lb/ub
        if np.size(self.lb) == 1:
            self.lb = np.full(self.dimensions, self.lb)
        if np.size(self.ub) == 1:
            self.ub = np.full(self.dimensions, self.ub)
        
        # in case lb/ub is a list or tuple
        self.lb = np.array(self.lb)
        self.ub = np.array(self.ub)

        assert np.size(self.lb) == np.size(self.ub) == self.dimensions, \
            "optimizer.lb and optimizer.ub should be of equal size or scalar"
            
        assert (self.lb < self.ub).all(), \
            "optimizer.lb should be strictly lower than optimizer.ub"

        assert callable(self.evaluation_function), \
            "optimizer.evaluation_function should be callable"
        
        if self.safe_evaluation is None:
            self.safe_evaluation = False
        assert isinstance(self.safe_evaluation, bool), \
            "optimizer.safe_evaluation should be True/False"
        if not self.safe_evaluation:
            self._evaluation_function_safe = self.evaluation_function

        assert isinstance(self.objectives, int) and self.objectives > 0, \
            "optimizer.objectives should be positive integer"
        
        assert isinstance(self.constraints, int) and self.constraints >= 0, \
            "optimizer.constraints should be non-negative integer"
        
        if self.objective_weights is None:
            self.objective_weights = np.ones(self.objectives) / self.objectives   
        else:
            assert len(self.objective_weights) == self.objectives, \
                "optimizer.objective_weights list should contain number of elements equal to optimizer.objectives"
        
        if self.objective_labels is None:
            self.objective_labels = [f'obj_{o}' for o in range(self.objectives)]
        else:
            assert len(self.objective_labels) == self.objectives, \
                "optimizer.objective_labels list should contain number of strings equal to optimizer.objectives"
            
        if self.constraint_labels is None:
            self.constraint_labels = [f'cnstr_{c}' for c in range(self.constraints)]
        else:
            assert len(self.constraint_labels) == self.constraints, \
                "optimizer.constraint_labels list should contain number of strings equal to optimizer.constraints"

        assert (isinstance(self.number_of_processes, int) and self.number_of_processes > 0)\
               or self.number_of_processes == 'max', \
               "optimizer.number_of_processes should be positive integer or \'max\'"
        if self.number_of_processes == 'max':
            self.number_of_processes = multiprocessing.cpu_count()       
        if self.number_of_processes > 1: 
            if self.monitoring == 'basic':
                self.log(f'Preparing a multiprocess pool for {self.number_of_processes} processes.')
            try:
                self.pool = multiprocessing.Pool(self.number_of_processes)
            except RuntimeError as err:
                assert not "if __name__ == '__main__'" in str(err), \
                    "Parallel evaluation (optimizer.number_of_processes > 1) needs for Indago optimizers to be started inside \"if __name__ == '__main__':\""
                raise err
                
        if self.max_iterations:
            assert isinstance(self.max_iterations, int) and self.max_iterations > 0, \
                "optimizer.max_iterations should be positive integer"

        if self.max_evaluations:
            assert isinstance(self.max_evaluations, int) and self.max_evaluations > 0, \
                "optimizer.max_evaluations should be positive integer"

        if self.max_stalled_iterations:
            assert isinstance(self.max_stalled_iterations, int) and self.max_stalled_iterations > 0, \
                "optimizer.max_stalled_iterations should be positive integer"
            self._stalled_it = 0
        
        if self.max_stalled_evaluations:
            assert isinstance(self.max_stalled_evaluations, int) and self.max_stalled_evaluations > 0, \
                "optimizer.max_stalled_evaluations should be positive integer"
            self._stalled_eval = 0

        if not (self.max_iterations or self.max_evaluations
                or self.max_stalled_iterations or self.max_stalled_evaluations
                or self.target_fitness is not None):
            # self.max_iterations = 100 * self.dimensions
            self.max_evaluations = 50 * self.dimensions ** 2

        if self.eval_fail_behavior:
            assert self.eval_fail_behavior == 'abort' or \
                    self.eval_fail_behavior == 'retry' or \
                    self.eval_fail_behavior == 'ignore', \
                "optimizer.eval_fail_behavior should be 'abort'/'retry'/'ignore'"
        else:
            self.eval_fail_behavior = 'abort'
        
        if self.eval_retry_attempts:
            assert isinstance(self.eval_retry_attempts, int) and self.eval_retry_attempts > 0, \
                "optimizer.eval_retry_attempts should be positive integer"
        else:
            self.eval_retry_attempts = 10
        if self.eval_retry_recede:
            assert isinstance(self.eval_retry_recede, float) and 0 < self.eval_retry_recede < 1, \
                "optimizer.eval_retry_attempts should be positive number in range (0,1)"
        else:
            self.eval_retry_recede = 0.01

        assert isinstance(self.monitoring, str) or self.monitoring is None, \
            'optimizer.monitoring should be a string or None'

        if isinstance(self.monitoring, str):
            # Convert to lowercase string
            self.monitoring = self.monitoring.lower()
        if self.monitoring is None:
            self.monitoring = 'none'

        if self.monitoring == 'none':
            self._progress_log = lambda: None
            # self._progress_log = lambda: \
            #     self.__convergence_log_line() if self.convergence_log_file else None

        elif self.monitoring == 'basic':
            # self.progress_log = self._progress_log2
            self.rich_console = Console(highlight=False)

            self._progress_log = self.__progress_log_basic
            # if self.convergence_log_file:
            #     self._progress_log = lambda: \
            #         (self.__progress_log_basic(), self.__convergence_log_line())
            # else:
            #     self._progress_log = self.__progress_log_basic

        elif self.monitoring == 'dashboard':
            self._progress_log = lambda: None
            # self._progress_log = lambda: \
            #     self.__convergence_log_line() if self.convergence_log_file else None

            self.__progress_bar = Progress(
                "[progress.description]{task.description}",
                # "{task.completed:} of {task.total}",
                BarColumn(),
                "[progress.percentage]{task.percentage:>3.0f}%",
                # "Elapsed: {task.time_remaining}",
                "Elapsed:",
                TimeElapsedColumn(),
                "Remaining:",
                TimeRemainingColumn(),
                # expand=True,
            )
            
            self.__progress_bar_tasks = []
            
            if self.max_iterations:
                self.__progress_bar_tasks.append(self.__progress_bar.add_task("Iterations:",
                                                                          total=self.max_iterations))
            if self.max_stalled_iterations:
                self.__progress_bar_tasks.append(self.__progress_bar.add_task("Stalled iterations:",
                                                                              total=self.max_stalled_iterations))
            if self.max_evaluations:
                self.__progress_bar_tasks.append(self.__progress_bar.add_task("Evaluations:",
                                                                              total=self.max_evaluations))            
            if self.max_stalled_evaluations:
                self.__progress_bar_tasks.append(self.__progress_bar.add_task("Stalled evaluations:",
                                                                              total=self.max_stalled_evaluations))
            if self.target_fitness:
                self.__progress_bar_tasks.append(self.__progress_bar.add_task("Target fitness:", total=1))

            def update_progress_bar():

                progress = []
                report_str = ''

                if self.max_iterations:
                    progress.append(self.it)
                    report_str += f'\nCurrent iteration: {self.it}'

                if self.max_stalled_iterations:
                    progress.append(self._stalled_it)
                    report_str += f'\nStalled iterations: {self._stalled_it}'
                
                if self.max_evaluations:
                    progress.append(self.eval)
                    report_str += f'\nTotal evaluations: {self.eval}'
                
                if self.max_stalled_evaluations:
                    progress.append(self._stalled_eval)
                    report_str += f'\nStalled evaluations: {self._stalled_eval}'
                    # This is related to the Issue #29 reported in GitLab
                    # self.log(f'{self._stalled_eval}, {self.max_stalled_evaluations}')
                    # report_str += f'\nStalled evaluations: {np.min([self._stalled_eval, self.max_stalled_evaluations])}'

                if self.target_fitness and self.best:
                    f0 = None
                    # for h in self.results.cHistory:
                    #     if np.all(h[2].C <= 0):
                    #         f0 = h[2].f
                    #         break
                    # print(f'{f0=}')
                    for i in range(self.it + 1):
                        if np.all(self.history['C'][i, :] <= 0):
                            f0 = self.history['f'][i, 0]
                            break
                    if f0:
                        if self.best.f > self.target_fitness:
                            p = (self.best.f - self.target_fitness) / (f0 - self.target_fitness)
                        else:
                            p = 0
                        p = 1 - np.log10(1 + 9 * p)
                    else:
                        p = 0
                    progress.append(p)

                for task, prog in zip(self.__progress_bar_tasks, progress):
                    self.__progress_bar.update(task, completed=prog)

                best = 'None'
                if self.best is not None:
                    best = str(self.best.X)
                    all_labels = self.objective_labels + self.constraint_labels
                    label_len = np.max([len(lbl) for lbl in all_labels]) + 3
                    for o, o_label in zip(self.best.O, self.objective_labels):
                        best += '\n[magenta]' + f'{o_label}: '.rjust(label_len) + f'{o:12.5e} [/magenta]'
                    for c, c_label in zip(self.best.C, self.constraint_labels):
                        if c <= 0:
                            best += '\n[green]' + f'{c_label}: '.rjust(label_len) + f'{c:12.5e}   => (satisfied)[/green]'
                        else:
                            best += '\n[red]' + f'{c_label}: '.rjust(label_len) + f'{c:12.5e}   => (violated)[/red]'
                    best += '\n' + 'Fitness: '.rjust(label_len) + f'{self.best.f:12.5e}' + '\n'
                report_str += f'\nBest:\n{best}'
                
                return Group(f'[b]INDAGO[/b] {indago.__version__}',
                             f'{self.__class__.__name__} optimization running\n',
                             self.__progress_bar,
                             report_str)
            
            self.__update_progress_bar = update_progress_bar
            self.live = Live(self.__update_progress_bar())

        else:
            assert False, f'Unknown value for optimizer.monitoring parameter ({self.monitoring})'
        
        # Initialize optimization counters
        self.eval = 0
        self.it = 0

        # Initialize history
        self.history = {'eval': np.full([0, 1], 0),
                        'X': np.full([0, self.dimensions], np.zeros(self.dimensions)),
                        'O': np.full([0, self.objectives], np.zeros(self.objectives)),
                        'C': np.full([0, self.constraints], np.zeros(self.constraints)),
                        'f': np.full([0, 1], 0),
                        }
        # self.results = OptimizationResults(self)
        # self.results.cHistory = []
        self._init_convergence_log()

    def _evaluate_initial_candidates(self):
        """Private method for evaluating initial canidates.
        TODO

        Returns
        -------
        None
            Nothing
        """
        
        if self.X0 is not None:
            assert isinstance(self.X0, np.ndarray) or isinstance(self.X0, int), \
                "optimizer.X0 should be 1d or 2d np.array, or integer"
            if isinstance(self.X0, np.ndarray):
                assert self.X0.ndim == 1 or self.X0.ndim == 2, \
                    "optimizer.X0 should be 1d or 2d np.array"
                # if 1D convert to 2D
                if self.X0.ndim == 1:
                    self.X0 = np.array([self.X0])
                self._cs0 = np.array([CandidateState(optimizer=self) for i in range(self.X0.shape[0])])
                for i, cs in enumerate(self._cs0):
                    cs.X = self.X0[i, :]

            if isinstance(self.X0, int):
                assert self.X0 > 0, \
                    "optimizer.X0 should be a positive integer"
                n = self.X0
                self._cs0 = np.array([CandidateState(optimizer=self) for i in range(n)])
                for cs in self._cs0:
                    cs.X = np.random.uniform(self.lb, self.ub, self.dimensions)
            self.log(f'Evaluating {self._cs0.size} initial candidates...')
            self.collective_evaluation(self._cs0)
            self._cs0 = np.sort(self._cs0)

            self.log(', '.join([f'{cs.f}' for cs in self._cs0]))
        else:
            self.best = None

    def _init_convergence_log(self):
        """Private method for initializing convergence log.
        TODO

        Returns
        -------
        None
            Nothing
        """
        
        # Initialize convergence log file
        if self.convergence_log_file:
            if os.path.exists(self.convergence_log_file):
                os.remove(self.convergence_log_file)

            summary = ['#']
            summary.append(f'#  Indago {type(self).__name__} Optimizer')
            summary.append('#')
            for var, value in vars(self).items():
                if not var.startswith('_'):
                    if var in ['it', 'eval', 'history']:
                        continue
                    if isinstance(value, (int, float, str, bool)):
                        summary.append(f'#  {var:25}: {str(value)}')
                    if isinstance(value, (list, dict)) and len(value) > 0:
                        summary.append(f'#  {var:25}: {str(value)}')
                    if isinstance(value, np.ndarray) and np.size(value) > 0:
                        if isinstance(value[0], (int, float)):
                            summary.append(f'#  {var:25}: {np.array_str(value, max_line_width=np.inf)}')
            # for k, v in self.params.items():
            #     summary.append(f'#  {k:25}: {v}')

            summary.append('#')
            summary.append(f'#  Optimization started at {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
            summary.append('#')

            # Columns header
            line = 'Iterations'.rjust(12)
            line += 'Evaluations'.rjust(15)
            line += ''.join([f'x{i+1}'.rjust(20) for i in range(self.dimensions)])
            for ol in self.objective_labels:
                line += f'{ol}'[:19].strip().rjust(20)
            for cl in self.constraint_labels:
                line += f'{cl}'[:19].strip().rjust(20)
            line += 'Fitness'.rjust(20)

            log_file = open(self.convergence_log_file, 'w')
            log_file.write('\n'.join(summary) + '\n')
            log_file.write('#' + line + '\n')
            log_file.write('#' + '-' * len(line) + '\n')
            log_file.close()

    def _check_params(self, mandatory_params, optional_params, defined_params):
        """Private method which checks if optimizer parameters are defined in Optimizer.params dictionary.
        Should be called in initializations of derived Optimizer classes. It asserts if any of 
        mandatory parameters is missing and prints a warning if unknown/excessive parameter is provided.

        Parameters
        ----------
        mandatory_params : list of str
            A list of mandatory parameter names/keys for given method.
        optional_params : list of str
            A list of optional parameter names/keys for given method.
        defined_params : list of str
            A list of method's parameters names/keys defined in Optimizer.params dictionary.

        Returns
        -------
        None
            Nothing
        """
        
        # checking for deprecated parameters
        assert 'method' not in dir(self), \
            'Error: \'optimizer.method\' parameter is deprecated. Please use \'optimizer.variant\'.'
        assert 'iterations' not in dir(self), \
            'Error: \'optimizer.iterations\' parameter is deprecated. Please use \'optimizer.max_iterations\'.'
        assert 'maximum_evaluations' not in dir(self), \
            'Error: \'optimizer.maximum_evaluations\' parameter is deprecated. Please use \'optimizer.max_evaluations\'.'
        assert 'maximum_stalled_iterations' not in dir(self), \
            'Error: \'optimizer.maximum_stalled_iterations\' parameter is deprecated. Please use \'optimizer.max_stalled_iterations\'.'
        assert 'maximum_stalled_evaluations' not in dir(self), \
            'Error: \'optimizer.maximum_stalled_evaluations\' parameter is deprecated. Please use \'optimizer.max_stalled_evaluations\'.'
        
        for param in mandatory_params:
            assert param in defined_params, f'Error: Missing parameter {param}'

        for param in defined_params:
            assert param in mandatory_params or param in optional_params, \
                f'Warning: Excessive parameter {param}'

    def __str__(self):
        """Method for a useful printout of optimizer properties. 
        
        Returns
        -------
        printout : str - TODO check
            String of the fancy table of optimizer properties.
        """
        
        table = Table(title=f'Indago {type(self).__name__} Optimizer')

        table.add_column('Property', justify='left', style='magenta')
        table.add_column('Value', justify='left', style='cyan')
        
        for var, value in vars(self).items():
            if var in ['it', 'eval', 'history']:
                continue
            if not var.startswith('_'):
                if isinstance(value, (int, float, str, bool)):
                    table.add_row(var, str(value))
                if isinstance(value, (list, dict)) and len(value) > 0:
                    table.add_row(var, str(value))
                if isinstance(value, np.ndarray) and np.size(value) > 0:
                    if isinstance(value[0], (int, float)):
                        table.add_row(var, np.array_str(value, max_line_width=np.inf))
        
        # the following are important, explicitly adding them to printout
        if self.best:
            table.add_row('best.X', np.array_str(self.best.X, max_line_width=np.inf), style='bold')
            table.add_row('best.f', str(self.best.f), style='bold')
        
        console = Console()
        with console.capture() as capture:
            console.print(table)
        return capture.get()

    def log(self, msg, indent=0):
        """Utility function for consistently displaying messages in the output.

        Parameters
        ----------
        msg : str
            Message to be displayed in the output.
        indent : int
            Number of characters used for indentation of the message. Default is 0.
        
        Returns
        -------
        None
            Nothing
        """
        
        if self.monitoring == 'basic':
            print(' ' * indent + msg)
        if self.convergence_log_file:
            log_file = open(self.convergence_log_file, 'a')
            log_file.write(f'#  {msg}\n')
            log_file.close()

    def __progress_log_basic(self):
        """TODO
        
        Returns
        -------
        None
            Nothing
        """
        
        line = f'iter: {self.it:{int(np.ceil(np.log10(self.max_iterations))) + 1}d}/{self.max_iterations}'
        line += f', eval: {self.eval:10d}'
        if self.max_evaluations:
            line += f'/{self.max_evaluations}'
        for io, ol in enumerate(self.objective_labels):
            line += f', {ol}: [cyan]{self.best.O[io]}[/cyan]'
        for cl, cv in zip(self.constraint_labels, self.best.C):
            if cv > 0:
                line += f', {cl}: [green bold]{cv}[/green bold]'
            else:
                line += f', {cl}: [green bold]{cv}[/green bold]'
        line += f', fit: [magenta bold]{self.best.f}[/magenta bold]'
        self.rich_console.print(line)
        #self.log(line)

    def __convergence_log_line(self):
        """TODO
        
        Returns
        -------
        None
            Nothing
        """
        
        if not self.convergence_log_file:
            return

        line = f'{self.it}'.rjust(13)
        line += f'{self.eval}'.rjust(15)
        line += ''.join([f'{x:18.12e}'.rjust(20) for x in self.best.X])
        for o in self.best.O:
            line += f'{o:18.12e}'.rjust(20)
        for c in self.best.C:
            line += f'{c:18.12e}'.rjust(20)
        line += f'{self.best.f:18.12e}'.rjust(20)
        line += '\n'

        log_file = open(self.convergence_log_file, 'a')
        log_file.write(line)
        log_file.close()

    def _gen_unique_str(self):
        """Private method for generating a unique string of 16 characters.
        This string is then used when forwarding a unique string to the evaluation function 
        is enabled (Optimizer.forward_unique_str = True). The generated string is guaranteed to be
        unique within an optimizer.

        Returns
        -------
        s : str
            A unique string.
        """
        
        while True:
            _s = ''.join(np.random.choice([c for c in string.ascii_lowercase]) for _ in range(16))
            if _s not in self._unique_str_list:
                self._unique_str_list.append(_s)
                return _s

    def optimize(self, seed=None):
        """A method which starts the optimization. The method wraps ``_run`` method 
        of the optimizer's subclass.

        Parameters
        ----------
        seed : int or None
            A random seed. Use the same value for reproducing identical stochastic procedures.

        Returns
        -------
        optimum : OptimizationResults
            The best solution found in the optimization.
        """
        
        self._init_optimizer()
        np.random.seed(seed)

        if self.monitoring == 'dashboard':
            with self.live:
                r = self._run()
        else:
            r = self._run()

        if self.convergence_log_file:
            summary = ['#', f'#  Optimization finished at {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}', '#']
            log_file = open(self.convergence_log_file, 'a')
            log_file.write('\n'.join(summary))
            log_file.close()
        
        # terminate multiprocessing pool if used
        if hasattr(self, 'pool'):
            self.pool.terminate()

        return r

    def _stopping_criteria(self):
        """A private method used to evaluate all specified stopping conditions.

        Returns
        -------
        stop : bool
            ``True`` or ``False``, whether the optimization should stop.
        """
        
        # Stop if maximum number of iterations reached
        if self.max_iterations:
            if self.it > self.max_iterations:
                self.log(f'Maximum number of iterations reached ({self.max_iterations})')
                return True
            
        # Stop if maximum number of evaluations surpassed
        if self.max_evaluations:
            if self.eval > self.max_evaluations:
                self.log(f'Maximum number of evaluations reached ({self.eval})')
                return True

        # Stop if fitness threshold achieved
        if self.target_fitness:
            if self.best.f < self.target_fitness and np.all(self.best.C <= 0):
                self.log(f'Target fitness achieved {self.best.f} < {self.target_fitness}')
                return True

        # Stop if maximum number of stalled iterations surpassed
        if self.max_stalled_iterations:
            f_hist = self.history['f'].flatten()
            # f_hist = np.array([r[2].f for r in self.results.cHistory])
            self._stalled_it = np.size(np.where(f_hist == f_hist[-1])) - 1
            if self._stalled_it > self.max_stalled_iterations:
                self.log(f'Stalled for the last {self._stalled_it} iterations, optimization stopped')
                return True
            
        # Stop if maximum number of stalled evaluations surpassed
        if self.max_stalled_evaluations:
            f_hist = self.history['f'].flatten()
            # f_hist = np.array([r[2].f for r in self.results.cHistory])
            stalled_it = np.size(np.where(f_hist == f_hist[-1])) - 1
            # print(f'{stalled_it=}, {self.it=}')
            if stalled_it > self.it:
                self._stalled_eval = self.eval
            else:
                self._stalled_eval = self.eval - self.history['eval'][-stalled_it - 1][0]
            # print(f'new: {self.stalled_eval=}')
            # self.stalled_eval = self.eval - self.results.cHistory[-stalled_it][1]
            # print(f'old: {self.stalled_eval=}')
            if self._stalled_eval > self.max_stalled_evaluations:
                self.log(f'Stalled for the last {self._stalled_eval} evaluations, optimization stopped')
                return True
        
        # No criteria satisfied
        return False

    def _finalize_iteration(self):
        """A private method used to perform all administrative tasks at the end of a method's iteration.
    
        Returns
        -------
        stop : bool
            ``True`` or ``False``, whether the optimization should stop.
        """

        self._progress_log()

        if self.convergence_log_file:
            self.__convergence_log_line()

        # Post iterational processing
        if self.post_iteration_processing:
            _candidates = np.sort(self._evaluated_candidates)
            self.post_iteration_processing(self.it, _candidates, self.best)

        # Flushing evaluated candidates
        self._evaluated_candidates = np.array([], dtype=CandidateState)
        
        # Counting iterations
        self.it += 1
        
        return self._stopping_criteria()
    

    """
    # These two functions were added to fix some multiprocessing-related problems on HPC.
    # Since then, a cleaner fix for this kind of problems was implemented (commit bb5d22cd).
    # These two are now commented out, hopefully they will not be missed by anyone. :)
    
    def __getstate__(self):
        
        self_dict = self.__dict__.copy()
        # del wrapped in try-except to avoid KeyError (no 'pool' in self_dict)
        if 'pool' in self_dict:
            del self_dict['pool']
        else:
            self.log('Unexpected behaviour! Please report this issue to authors. There is no \'pool\' key in self.__dict__' )
        # try:
        #     del self_dict['pool']
        # except:
        #     pass
        return self_dict

    def __setstate__(self, state):
        self.__dict__.update(state)
    """

    def _multiprocess_evaluate(self, candidates):
        """Private method used for calling parallel evaluation of multiple candidates. 
        It relies on multiprocessing pool using map and starmap methods. The objectives, 
        constraints and fitness of candidates in the list are updated after the evaluation.

        Parameters
        ----------
        candidates : list of CandidateState
            A list of candidates to be evaluated.

        Returns
        -------
        err_msg : str or None
            Error message (if any).
        """
        
        err_msg = None
        
        if self.forward_unique_str:
            for p in range(len(candidates)):
                candidates[p].unique_str = self._gen_unique_str()
            result = self.pool.starmap(self._evaluation_function_safe,
                                       [(c.X, c.unique_str) for c in candidates])
        else:
            result = self.pool.map(self._evaluation_function_safe, [c.X for c in candidates])
        
        # if failed evaluation
        if np.isnan(result).any():
            self.eval_fail_count += np.sum(np.isnan(result))
            
            if self.eval_fail_behavior == 'ignore':
                # report to log evaluation failed
                if self.monitoring == 'basic':
                    self.log('evaluation function failed, ignoring')
            
            elif self.eval_fail_behavior == 'abort':
                # report to log evaluation failed
                if self.monitoring == 'basic':
                    self.log('evaluation function failed, aborting optimization')
                return 'EVALUATION FAILED.' 
            
            elif self.eval_fail_behavior == 'retry':                      
                for i in range(self.eval_retry_attempts):
                    # report to log commencing retry
                    if self.monitoring == 'basic':
                        self.log(f'evaluation function failed, attempting retry nr. {i+1}')            
                    if self.best:
                        goodX = self.best.X
                    else:
                        goodX = (self.lb + self.ub)/2
                    for p in np.nonzero(np.isnan(result))[0]:
                        candidates[p].X += self.eval_retry_recede * \
                                            (goodX - candidates[p].X)
                        if self.forward_unique_str:
                            candidates[p].evaluate(self._gen_unique_str())
                        else:
                            candidates[p].evaluate()
                        self.eval += 1
                        # if successful, transfer fitness info back to result
                        if not np.isnan(candidates[p].f):
                            if self.objectives == 1 and self.constraints == 0:
                                result[p] = candidates[p].f
                            else:
                                result[p] = (*candidates[p].O, *candidates[p].C)
                        else:
                            self.eval_fail_count += 1
                    if not np.isnan(result).any():
                        break
                else:
                    if self.eval_retry_attempts > 0:
                        # report to log retrying exhausted
                        if self.monitoring == 'basic':
                            self.log('evaluation function retry attempts exhausted, aborting optimization')
                        return 'TOO MANY FAILED EVALUATIONS.'

        if self.objectives == 1 and self.constraints == 0:
            # Fast evaluation
            for p in range(len(candidates)):
                candidates[p].f = result[p]
                candidates[p].O[0] = result[p]
        else:
            # Full evaluation
            for p in range(len(candidates)):
                for io in range(self.objectives):
                    candidates[p].O[io] = result[p][io]
                candidates[p].f = np.dot(candidates[p].O, self.objective_weights)
                for ic in range(self.constraints):
                    candidates[p].C[ic] = result[p][self.objectives + ic]
        
        # return error message if any
        if err_msg:
            return err_msg

    def collective_evaluation(self, candidates):
        """Function used for evaluation of multiple candidates which automatically conducts parallel or serial
        evaluation and forwards a unique string to the evaluation function. Evaluation is performed in-place and the
        candidates provided as argument are updated.

        Parameters
        ----------
        candidates : list of CandidateState
            A list of candidates to be evaluated.

        Returns
        -------
        err_msg : str or None
            Error message (if any).
        """
        
        err_msg = None
        
        n = len(candidates)
        self.eval += n

        if self.number_of_processes > 1:
            err_msg = self._multiprocess_evaluate(candidates)
        
        else:
            for p in range(n):
                if self.forward_unique_str:
                    candidates[p].unique_str = self._gen_unique_str()
                    candidates[p].evaluate(candidates[p].unique_str)
                else:
                    candidates[p].evaluate()

                # if failed evaluation
                if np.isnan(candidates[p].f):
                    self.eval_fail_count += 1

                    if self.eval_fail_behavior == 'ignore':
                        # report to log evaluation failed
                        if self.monitoring == 'basic':
                            self.log('evaluation function failed, ignoring')

                    elif self.eval_fail_behavior == 'abort':
                        # report to log evaluation failed
                        if self.monitoring == 'basic':
                            self.log('evaluation function failed, aborting optimization')
                        return 'EVALUATION FAILED.'

                    elif self.eval_fail_behavior == 'retry':
                        for i in range(self.eval_retry_attempts):
                            # report to log commencing retry
                            if self.monitoring == 'basic':
                                self.log(f'evaluation function failed, attempting retry nr. {i+1}')
                            if self.best:
                                goodX = self.best.X
                            else:
                                goodX = (self.lb + self.ub)/2
                            candidates[p].X += self.eval_retry_recede * \
                                                (goodX - candidates[p].X)
                            if self.forward_unique_str:
                                candidates[p].evaluate(self._gen_unique_str())
                            else:
                                candidates[p].evaluate()
                            self.eval += 1
                            if not np.isnan(candidates[p].f):
                                break
                            else:
                                self.eval_fail_count += 1
                        else:
                            if self.eval_retry_attempts > 0:
                                # report to log retrying exhausted
                                if self.monitoring == 'basic':
                                    self.log('evaluation function retry attempts exhausted, aborting optimization')
                                return 'TOO MANY FAILED EVALUATIONS.'

        # Expand list of candidates evaluated in current iteration
        self._evaluated_candidates = np.append(self._evaluated_candidates, np.array([c.copy() for c in candidates]))

        # Determine new best candidate state
        if self.best is None:
            self.best = np.sort(candidates)[0].copy()
        else:
            # self.best = np.min(np.append(candidates, [self.best])).copy()
            candidates_best = np.sort(candidates)[0]
            if candidates_best.f == self.best.f:
                if np.any(candidates_best.X != self.best.X):
                    self.log(f'Warning: nonunique optimum; multiple best candidates with same fitness but different X. [{", ".join(f"{x:18.12e}" for x in candidates_best.X)}]')
            if candidates_best <= self.best:
                self.best = candidates_best.copy()

        # Post iterational processing
        # if self.post_iteration_processing:
        #     _candidates = np.sort(candidates)
        #     self.post_iteration_processing(self.it, _candidates, self.best)

        # Record to history
        self._update_history()
        
        if self.monitoring == 'dashboard':
            self.live.update(self.__update_progress_bar())
        
        # return error message if any
        if err_msg:
            return err_msg

    def _update_history(self):
        """TODO

        Returns
        -------
        None
            Nothing
        """

        if self.history['eval'].size < self.it + 1:
            self.history['eval'] = np.vstack([self.history['eval'], self.eval])
            self.history['X'] = np.vstack([self.history['X'], self.best.X])
            self.history['O'] = np.vstack([self.history['O'], self.best.O])
            self.history['C'] = np.vstack([self.history['C'], self.best.C])
            self.history['f'] = np.vstack([self.history['f'], self.best.f])
        else:
            self.history['eval'][-1] = self.eval
            self.history['X'][-1] = self.best.X
            self.history['O'][-1] = self.best.O
            self.history['C'][-1] = self.best.C
            self.history['f'][-1] = self.best.f

    def plot_history(self, axes=None):
        """Plot convergence. TODO

        Parameters
        ----------
        axes : TODO

        Returns
        -------
        None
            Nothing
        """

        fig = None
        if axes is None:
            fig, axes = plt.subplots(figsize=(6, 8), nrows=4, constrained_layout=True, sharex=True)
        ax_x, ax_o, ax_c, ax_f = axes

        E = self.history['eval'][:, 0]

        # Plot optimization variables
        if ax_x:
            for i in range(self.dimensions):
                X = self.history['X'][:, i]
                ax_x.plot(E, X, lw=0.5,
                          label=None if self.dimensions > 10 else f'$x:{i + 1}$')
            ax_x.set_ylabel('Optimizaion variables')
            if self.dimensions > 10:
                ax_x.legend()

        # Plot objectives
        if ax_o:
            for i in range(self.objectives):
                O = self.history['O'][:, i]
                ax_o.plot(E, O, lw=0.5, label=self.objective_labels[i])
            ax_o.set_ylabel('Objectives')
            ax_o.legend()

        # Plot constraints
        i_feasible = 0
        for i in range(self.it + 1):
            if np.all(self.history['C'][i, :] <= 0):
                i_feasible = i
                break
        if ax_c:
            for i in range(self.constraints):
                C = self.history['C'][:, i]
                ax_c.plot(E, C, lw=0.5, label=self.constraint_labels[i])
                I = C > 0
                ax_c.plot(E[I], C[I], 'r.')
            c_lim = ax_c.get_ylim()[0], ax_c.get_ylim()[1]
            ax_c.fill_between(E[:i_feasible], ax_c.get_ylim()[0], ax_c.get_ylim()[1],
                              color='red', alpha=0.2)
            ax_c.axhline(0, c='r', lw=0.5)
            ax_c.set_ylim(c_lim)
            ax_c.set_ylabel('Constraints')
            ax_c.legend()

        # Plot fitness
        if ax_f:
            F = self.history['f'][:, 0]
            ax_f.plot(E, F, lw=1, c='k')
            f_lim = ax_f.get_ylim()[0], ax_f.get_ylim()[1]
            ax_f.fill_between(E[:i_feasible], f_lim[0], f_lim[1],
                              color='red', alpha=0.2)
            ax_f.set_ylim(f_lim)
            ax_f.set_ylabel('Fitness')
            ax_f.set_xlabel('Evaluations')

        if fig:
            fig.align_ylabels(axes)

class CandidateState:
    """Base class for search agents in all optimization methods. 
    Candidate solution for the optimization problem.
    
    Parameters
    ----------
    X : ndarray
        Design vector.
    O : ndarray
        Objectives' values.
    C : ndarray
        Constraints' values.
    f : float
        Fitness.
    _optimizer : Optimizer
        Optimizer in which CandidateState is being used. Used for accessing 
        Optimizer parameters such as evaluation function, dimensions, bounds, etc.
    _evaluate : callable
        Method for evaluating candidate solution.
    
    Returns
    -------
    CandidateState
        CandidateState instance
    """
    
    def __init__(self, optimizer: Optimizer):        
        self._optimizer = optimizer
        self.X = np.zeros(optimizer.dimensions) * np.nan
        self.O = np.zeros(optimizer.objectives) * np.nan
        self.C = np.zeros(optimizer.constraints) * np.nan
        self.f = np.nan
                    
        # Evaluation
        if self._optimizer.objectives == 1 and self._optimizer.constraints == 0:
            self._evaluate = self._eval_fast
        else:
            self._evaluate = self._eval_full
        
        # Comparison operators
        if self._optimizer.objectives == 1 and self._optimizer.constraints == 0:
            self._eq_fn = self._eq_fast
            self._lt_fn = self._lt_fast 
            #self.__gt__ = self._gt_fast
        else:
            self._eq_fn = self._eq_full
            self._lt_fn = self._lt_full 
            #self.__gt__ = self._gt_full

        if self._optimizer.forward_unique_str:
            self.unique_str = None
            
    def clip(self):
        """Method for clipping (trimming) the design vector (CandidateState.X) 
        values to lower and upper bounds.
        
        Returns
        -------
        None
            Nothing
        """
        
        self.X = np.clip(self.X, self._optimizer.lb, self._optimizer.ub)

    def copy(self):
        """Method for creating a true (deep) copy of the CandidateState. 
        
        Returns
        -------
        CandidateState
            CandidateState instance
        """
        
        cP = self.__class__(self._optimizer)         
        # Real copy for ndarrays
        cP.X = np.copy(self.X)
        cP.O = np.copy(self.O)
        cP.C = np.copy(self.C)
        cP.f = self.f

        if self._optimizer.forward_unique_str:
            cP.unique_str = self.unique_str

        return cP    
    
    def __str__(self):
        """Method for a useful printout of candidate solution properties. 
        
        Returns
        -------
        printout : str - TODO check
            String of the fancy table of candidate solution properties.
        """
        
        table = Table(title=f'Indago {type(self).__name__} (subclass of CandidateState)')

        table.add_column('Property', justify='left', style='magenta')
        table.add_column('Value', justify='left', style='cyan')
        
        for var, value in vars(self).items():
            if not var.startswith('_'):
                if isinstance(value, (int, float, str, bool)):
                    table.add_row(var, str(value))
                if isinstance(value, (list, dict)) and len(value) > 0:
                    table.add_row(var, str(value))
                if isinstance(value, np.ndarray) and np.size(value) > 0:
                    if isinstance(value[0], (int, float)):
                        table.add_row(var, np.array_str(value, max_line_width=np.inf))
        
        console = Console()
        with console.capture() as capture:
            console.print(table)
        return capture.get()
        
    def __eq__(self, other): 
        """Equality operator wrapper. 
        
        Parameters
        ----------
        other : CandidateState
            The other of the two candidate solutions.
        
        Returns
        -------
        equal : bool
            ``True`` if candidate solutions are equal, ``False`` otherwise.
        """
        
        return self._eq_fn(self, other) 
    
    @staticmethod
    def _eq_fast(a, b):
        """Private method for fast candidate solution equality check. 
        Used in single objective, unconstrained optimization.
        
        Parameters
        ----------
        a : CandidateState
            The first of the two candidate solutions.
        b : CandidateState
            The second of the two candidate solutions.            
        
        Returns
        -------
        equal : bool
            ``True`` if candidate solutions are equal, ``False`` otherwise.
        """
        
        return a.f == b.f

    @staticmethod
    def _eq_full(a, b):
        """Private method for full candidate solution equality check. 
        Used in multiobjective and/or constrained optimization.
        
        Parameters
        ----------
        a : CandidateState
            The first of the two candidate solutions.
        b : CandidateState
            The second of the two candidate solutions.            
        
        Returns
        -------
        equal : bool
            ``True`` if candidate solutions are equal, ``False`` otherwise.
        """
        
        # return np.sum(np.abs(a.X - b.X)) + np.sum(np.abs(a.O - b.O)) + np.sum(np.abs(a.C - b.C)) == 0.0
        return (a.X == b.X).all() and (a.O == b.O).all() and (a.C == b.C).all() and a.f == b.f

    def __ne__(self, other):
        """Inequality operator. 
        
        Parameters
        ----------
        other : CandidateState
            The other of the two candidate solutions.
        
        Returns
        -------
        not_equal : bool
            ``True`` if candidate solutions are not equal, ``False`` otherwise.
        """
        
        return self.f != other.f
    
    def __lt__(self, other):
        """Less-then operator wrapper. 
        
        Parameters
        ----------
        other : CandidateState
            The other of the two candidate solutions.
        
        Returns
        -------
        lower_than : bool
            ``True`` if the candidate solution is better than **other**, ``False`` otherwise.
        """
        
        return self._lt_fn(self, other)    

    @staticmethod
    def _lt_fast(a, b): 
        """Fast less-than operator. 
        Used in single objective, unconstrained optimization.
        
        Parameters
        ----------
        a : CandidateState
            The first of the two candidate solutions.
        b : CandidateState
            The second of the two candidate solutions.   
        
        Returns
        -------
        lower_than : bool
            ``True`` if **a** is better than **b**, ``False`` otherwise.
        """
        
        #print('_lt_fast')
        if np.isnan(a.f).any():
            return False
        if np.isnan(b.f).any():
            return True  
        return a.f < b.f   
    
    @staticmethod     
    def _lt_full(a, b):
        """Private method for full less-than check. 
        Used in multiobjective and/or constrained optimization.
        
        Parameters
        ----------
        a : CandidateState
            The first of the two candidate solutions.
        b : CandidateState
            The second of the two candidate solutions.            
        
        Returns
        -------
        lower_than : bool
            ``True`` if **a** is better than **b**, ``False`` otherwise.
        """
        
        #print("_lt_full")
        if np.isnan([*a.O, *a.C]).any():
            return False
        if np.isnan([*b.O, *b.C]).any():
            return True            
        if np.sum(a.C > 0) == 0 and np.sum(b.C > 0) == 0: 
            # Both are feasible
            # Better candidate is the one with smaller fitness
            return a.f < b.f
        
        elif np.sum(a.C > 0) == np.sum(b.C > 0): 
            # Both are unfeasible and break same number of constraints
            # Better candidate is the one with smaller sum of unfeasible (positive) constraint values
            return np.sum(a.C[a.C > 0]) < np.sum(b.C[b.C > 0])
        
        else:
            # The number of unsatisfied constraints is not the same
            # Better candidate is the one which breaks fewer constraints
            return np.sum(a.C > 0) < np.sum(b.C > 0)
       
    def __gt__(self, other):
        """Greater-then operator wrapper. 
        
        Parameters
        ----------
        other : CandidateState
            The other of the two candidate solutions.
        
        Returns
        -------
        greater_than : bool
            ``True`` if the candidate solution is worse than **other**, ``False`` otherwise.
        """
        
        #print('__gt__')
        #return self.f > other.f
        return not (self._lt_fn(self, other) or self._eq_fn(self, other))
    
    """
    def _gt_fast(self, other):
        return self.f > other.f
    def _gt_full(self, other): 
        return not (self.__eq__(other) or self.__lt__(other))
    """    
    
    def __le__(self, other):
        """Less-then-or-equal operator wrapper. 
        
        Parameters
        ----------
        other : CandidateState
            The other of the two candidate solutions.
        
        Returns
        -------
        lower_than_or_equal : bool
            ``True`` if the candidate solution is better or equal to **other**, ``False`` otherwise.
        """
        
        return self._lt_fn(self, other) or self.__eq__(other)
    
    def __ge__(self, other):
        """Greater-then-or-equal operator wrapper. 
        
        Parameters
        ----------
        other : CandidateState
            The other of the two candidate solutions.
        
        Returns
        -------
        greater_than_or_equal : bool
            ``True`` if the candidate solution is worse or equal to **other**, ``False`` otherwise.
        """
        
        return self.__gt__(other) or self.__eq__(other)

    # Do we need this?!
    def set_X(self, X: np.ndarray):
        """Method for setting design vector. 
        
        Parameters
        ----------
        X : ndarray
            Design vector to set.
        
        Returns
        -------
        None
            Nothing
        """

        assert np.size(self.X) == np.size(X), \
            'Unexpected size of optimization vector X'
        self.X = X
    
    def evaluate(self, s=None):
        """Wrapper for the **_evaluate** method. 
        
        Parameters
        ----------
        s : str
            Unique string.
        
        Returns
        -------
        None
            Nothing
        """
        
        self._evaluate(s)
        
    def _eval_fast(self, s=None):
        """Private method for fast evaluation of candidate solution.
        Used in single objective, unconstrained optimization.
        
        Parameters
        ----------
        s : str
            Unique string.
        
        Returns
        -------
        None
            Nothing
        """

        if s is None:
            f = self._optimizer._evaluation_function_safe(self.X)
        else:
            f = self._optimizer._evaluation_function_safe(self.X, s)

        self.f = f
        self.O[0] = f
        
        #return None #self.O, self.C, self.f
        
    def _eval_full(self, s=None):
        """Private method for full evaluation of candidate solution.
        Used in multiobjective and/or constrained optimization.
        
        Parameters
        ----------
        s : str
            Unique string.
        
        Returns
        -------
        None
            Nothing
        """

        if s is None:
            oc = self._optimizer._evaluation_function_safe(self.X)
        else:
            oc = self._optimizer._evaluation_function_safe(self.X, s)

        for io in range(self._optimizer.objectives):
            self.O[io] = oc[io]
        
        self.f = np.dot(self.O, self._optimizer.objective_weights)
        
        for ic in range(self._optimizer.constraints):
            self.C[ic] = oc[self._optimizer.objectives + ic]

        #return None #self.O, self.C, self.f
