import abc

import numpy as np
import os
import shutil
import joblib
import concurrent.futures as futures
import time
import inspect
import multiprocessing as mp
import pygmo as pg
from utils import Utils, Messages
from optimizers import Optimization
from models import Estim8Model, DymolaModel, FmuModel
import pandas as pd
from warnings import warn
from IPython.display import display
from pathlib import Path
from scipy.stats import rv_continuous, norm
from importlib import import_module
import matplotlib.pyplot as plt



def objective(par, mod_name: str, folders, rids: list, parameter_mapping: dict, t: list, bounds: dict, data, obs_map,
              metric, err_par, err_dat, LC_name, scr_name, par_defaults:dict):
    """
    Calculates the loss for a parallel parameter estimation. Instantiates global variable for the model and the
    LossCalculator on the subprocesses within the folder structure defined by Workers.__init__(), based on the mod_name.
    Executes the same procedure as Estimator.obj() afterwards, to calculate the model discrepancy with respect to a
    replicate handling.

    Arguments:
    ----------
        par : list
            Parameter values from the optimizer
        mod_name    : str
            Name of the model to instantiate / use for simulation
        folders     : list
            List of folders generated by Workers.__init__() to define current working directories.
        rids        : list
            Replicate IDs
        parameter_mapping : dict
            Dictionary of the parameter mapping
        t           : list
            List of time vector settings [start time, stop time, stepsize]
        bounds      : dict
            Boundaries for parameter estimation
        data        : dict
            Data converted to dict[dict] for replicate IDs and measurements. Is converted back to a pandas DataFrame
        obs_map     : dict
            Observation mapping for matching experimental data keys with simulation keys.
        err_dat     : dict
            Error data given by experimental data or calculated by custom error model.

    Raises:
    -------
        UserWarning :
            No free worker found -> Indicates initialization error in workers

    External Functions:
    -------------------
        sim_parallel()  : DymolaModel
            Simulate with randomly generated filenames in the specified directory.
        calculate_loss  : LossCalculator
            Get model discrepancy based on given method.
        select_dir      : Utils
            Find a folder which is not occupied by any other process
        replicate_handling : Utils
            Constructs a parameter dict for the sim_parallel method, with respect to specified replicates and parameter
            mapping

    """

    # -----------------------------------
    # -CHECK REQUIREMENTS FOR SUBPROCESS-
    # -----------------------------------
    # Import global variables
    global shadow_model, shadow_lc, w_id, _working_dir, shadow_est

    # Initialize local sim variable and loss
    sim = None
    loss = 0

    # Distribute tasks to different models and folders
    if '_working_dir' not in globals():
        _working_dir, w_id = Utils.select_dir(folders)
        if w_id == np.NaN:
            warn('no free worker found, queueing worker...')
            time.sleep(1)
            return objective(par, mod_name, folders, rids, parameter_mapping, t, bounds,
                             data, obs_map,metric, err_par, err_dat, LC_name, scr_name, par_defaults)

    # Change directory once available in globals
    os.chdir(_working_dir)


    # Reconvert data to pandas
    if rids is None:
        data = pd.DataFrame(data)
        if err_dat:
            err_dat = pd.DataFrame(err_dat)
    else:
        for rid in data.keys():
            data[rid] = pd.DataFrame(data[rid])
            if err_dat:
                err_dat[rid] = pd.DataFrame(err_dat[rid])


    if 'shadow_est' not in globals():
        shadow_est = _rebuild_estimator(mod_name, 'dymola', bounds, metric, data, t, par_defaults, scr_name, err_par=err_par,
                            err_dat=err_dat, obs_map=obs_map, par_map=parameter_mapping, LC_name=LC_name)
    # ----------------
    # -CALCULATE LOSS-
    # ----------------

    try:
        loss = shadow_est.obj(par)
    except:
        loss = np.inf

    try:
        os.remove('busy.txt')
    except FileNotFoundError:
        pass
    except PermissionError:
        pass

    return loss


def objective_fmu(par, mod_name: str, folders, rids: list, parameter_mapping: dict, t: list, bounds: dict, data, obs_map,
              metric, err_par, err_dat, LC_name, scr_name, par_defaults:dict):
    """
    Calculates the loss for a parallel parameter estimation. Instantiates global variable for the model and the
    LossCalculator on the subprocesses within the folder structure defined by Workers.__init__(), based on the mod_name.
    Executes the same procedure as Estimator.obj() afterwards, to calculate the model discrepancy with respect to a
    replicate handling.

    Arguments:
    ----------
        par : list
            Parameter values from the optimizer
        mod_name    : str
            Name of the model to instantiate / use for simulation
        folders     : list
            List of folders generated by Workers.__init__() to define current working directories.
        rids        : list
            Replicate IDs
        parameter_mapping : dict
            Dictionary of the parameter mapping
        t           : list
            List of time vector settings [start time, stop time, stepsize]
        bounds      : dict
            Boundaries for parameter estimation
        data        : dict
            Data converted to dict[dict] for replicate IDs and measurements. Is converted back to a pandas DataFrame
        obs_map     : dict
            Observation mapping for matching experimental data keys with simulation keys.
        err_dat     : dict
            Error data given by experimental data or calculated by custom error model.

    Raises:
    -------
        UserWarning :
            No free worker found -> Indicates initialization error in workers

    External Functions:
    -------------------
        sim_parallel()  : DymolaModel
            Simulate with randomly generated filenames in the specified directory.
        calculate_loss  : LossCalculator
            Get model discrepancy based on given method.
        select_dir      : Utils
            Find a folder which is not occupied by any other process
        replicate_handling : Utils
            Constructs a parameter dict for the sim_parallel method, with respect to specified replicates and parameter
            mapping


    """

    # -----------------------------------
    # -CHECK REQUIREMENTS FOR SUBPROCESS-
    # -----------------------------------
    # Import global variables
    global w_id, _working_dir, shadow_est

    # Initialize local sim variable and loss
    sim = None
    loss = 0

    # Distribute tasks to different models and folders
    if '_working_dir' not in globals():
        _working_dir, w_id = Utils.select_dir(folders, timeout=0.01)
        if w_id == np.NaN:
            warn('no free worker found, queueing worker...')
            time.sleep(1)
            return objective_fmu(par, mod_name, folders, rids, parameter_mapping, t, bounds,
                                 data, obs_map,metric, err_par, err_dat, LC_name, scr_name, par_defaults)

    # Change directory once available in globals
    if isinstance(_working_dir, str):
        os.chdir(_working_dir)
    else:
        print(f'Ignored Working dir: {_working_dir}.\n'
              f'Continuing in {str(Path().resolve())}')

    # Reconvert data to pandas
    if rids is None:
        data = pd.DataFrame(data)
        if err_dat:
            err_dat = pd.DataFrame(err_dat)
    else:
        for rid in data.keys():
            data[rid] = pd.DataFrame(data[rid])
            if err_dat:
                err_dat[rid] = pd.DataFrame(err_dat[rid])

    if 'shadow_est' not in globals():
        shadow_est = _rebuild_estimator(mod_name, 'fmu', bounds, metric, data, t, par_defaults, scr_name, err_par=err_par,
                          err_dat=err_dat, obs_map=obs_map, par_map=parameter_mapping, LC_name=LC_name)
    # ----------------
    # -CALCULATE LOSS-
    # ----------------
    try:
        loss = shadow_est.obj(par)
    except:
        loss = np.inf

    # Clear worker
    try:
        os.remove('busy.txt')
    except FileNotFoundError:
        pass
    except PermissionError:
        pass

    return loss

def objective_sk(par):
    args2pass = np.load('obj_args.npy', allow_pickle=True).item()                   # Read pickled args
    if args2pass['mod_type'] == 'dymola':                                           # Distiguish model type
        args2pass.pop('mod_type', None)                                             # Remove arg after distinction
        loss = objective(par, **args2pass)                                          # Run respecttive obj
    else:                                                                           # Same for FMUs
        args2pass.pop('mod_type', None)
        loss = objective_fmu(par, **args2pass)
    return loss


class PygmoProblem(object):
    def __init__(self, mod_name: str, folders, rids: list, parameter_mapping: dict, t: list, bounds: dict, data, obs_map,
              metric, err_par, err_dat, LC_name, scr_name, par_defaults:dict, mod_type:str):

        # Save input
        self.mod_name   = mod_name
        self.folders    = folders
        self.rids       = rids
        self.parameter_mapping = parameter_mapping
        self.t          = t
        self.bounds     = bounds
        self.data       = data
        self.obs_map    = obs_map
        self.metric     = metric
        self.err_par    = err_par
        self.err_dat    = err_dat
        self.LC_name    = LC_name
        self.scr_name   = scr_name
        self.mod_type   = mod_type
        self.par_default= par_defaults

    def fitness(self, x):
        if self.mod_type == 'dymola':
            loss = objective(   x,
                                self.mod_name,
                                self.folders,
                                self.rids,
                                self.parameter_mapping,
                                self.t,
                                self.bounds,
                                self.data,
                                self.obs_map,
                                self.metric,
                                self.err_par,
                                self.err_dat,
                                self.LC_name,
                                self.scr_name,
                                self.par_default)
        else:#elif self.mod_type == 'fmu':
            loss = objective_fmu(x,
                             self.mod_name + '.fmu',
                             self.folders,
                             self.rids,
                             self.parameter_mapping,
                             self.t,
                             self.bounds,
                             self.data,
                             self.obs_map,
                             self.metric,
                             self.err_par,
                             self.err_dat,
                             self.LC_name,
                             self.scr_name,
                             self.par_default,)
        return [loss]


    def get_bounds(self):
        bnd = self.bounds
        lb  = [b[0] for b in bnd.values()]
        ub  = [b[1] for b in bnd.values()]
        return lb,ub

    def get_name(self):
        return self.mod_name

    def get_extra_info(self):
        return self.bounds.keys()



class ErrorModel(abc.ABC):

    def __init__(self, error_params:dict=None):
        self._error_dist   = None
        self._error_params = error_params
        self._error_param_K= None
        self._dist_pars    = None

    @property
    def error_dist(self):
        return self._error_dist

    @error_dist.setter
    def error_dist(self, dist:rv_continuous):
        if not isinstance(dist, rv_continuous) and not None:
            raise IOError(f'error_dist must be aa entity of scipy.stats.rv_continuous')
        self._error_dist = dist  # TODO: Enable passing dist pars
        self._dist_pars  = None
        #warn('Distribution parameters have been reset.')


    @property
    def dist_pars(self):
        return self._dist_par

    @dist_pars.setter
    def dist_pars(self, pars):
        if isinstance(pars,dict):
            try:
                self.error_dist.rvs(**pars)
            except Exception as e:
                raise AttributeError(f'The specified distribution parameters do not match the required arguments of the'
                                     f' distribution: {e}')
        self._dist_pars = pars


    @property
    def error_params(self):
        return self._error_params

    @error_params.setter
    def error_params(self, pars: dict):
        if not isinstance(pars, dict) and not None:
            raise IOError(f'error_params must be provided as dict')
        if self._error_param_K is not None:
            if set(pars.keys()) - set(self._error_param_K):
                raise IOError(f'Keys for error parameters are not defined, use the keys: {self._error_param_K}')
        self._error_params = pars



    def get_sampling(self, values, error_params:dict=None, size:int=None):
        """
        Create a set of random varieties given a value as the location, error_parameters to feed the error model, and
        a distribution function, specified as property in ErrorModel.error_dist()

        Arguments:
        ----------
             values : list-like
                Values around which the distribution shall be centered.

        Keyword Arguments:
        ------------------
            error_params  : dict
                Parameters used for the error model.
            size          : int
                Size of the array to return. Default is the length of values

        Returns:
        --------
            rvs :
                Randomly sampled data around the given values as centers. Sampling within the range of the error
                predicted by the error model and multiplied by a random number in the given distribution.
        """


        # Check input
        if size is None:
            try:
                size = len(values)
            except Exception as e:
                warn(f'Problem in ErrorModel.get_rvs():\n{e}')

        # Check if distribution is defined
        if self._error_dist is None:
            self.error_dist = norm
            warn('No distribution specified, continuing sampling with normal distribution...')

        # Preserve index
        try:
            _ind            = values.index
        except:
            _ind            = None


        # Calculate Errors according to error model
        try:
            _err            = {}
            for _k in values.keys():
                _err[_k]    = values[_k].apply(lambda x: self.get_errors(x, error_params=error_params))
        except:
            val_df          = pd.DataFrame(values)
            _err            = val_df.apply(lambda x: self.get_errors(x, error_params=error_params))
            _k              = None

        # Apply distribution on errors
        try:
            rvs = {}
            for _k in _err.keys():
                ## Use dist_pars if available
                if self._dist_pars is None:
                    _err[_k]    = _err[_k].apply(lambda x: self.error_dist.rvs()*x)
                else:
                    _err[_k] = _err[_k].apply(lambda x: self.error_dist.rvs(**self._dist_pars) * x)
                rvs[_k]     = values[_k].values + _err[_k].values
            ## Make DataFrame
            rvs = pd.DataFrame(rvs, index=_ind)
        except:
            if self._dist_pars is None:
                _err = _err.apply(lambda x: self.error_dist.rvs()*x)
            else:
                _err = _err.apply(lambda x: self.error_dist.rvs(**self._dist_pars) * x)
            rvs  = val_df.values + _err.values
            rvs  = pd.DataFrame(rvs)

        if _ind is not None:
            rvs.index = _ind
        return rvs




    @abc.abstractmethod
    def get_errors(self, value, error_params):
        """
        Method for calculating errors of experimental data given a datapoint and a set of error model parameters.

        Arguments:
        ----------
            value : float / int
                Value on which to apply error model
            error_params : dict-like
                Structure containing the parameters required for the error model

        Returns:
        --------
            error : float
                Error calculated according to specified model
        """


class LinearErrorModel(ErrorModel):

    def __init__(self, error_params:dict=None):
        super().__init__(error_params=error_params)
        self._error_param_K = ['a', 'b', 'slope', 'offset']
        
    def get_errors(self, value, error_params:dict=None):

        # Initialize error parameters
        _a, _b      = None, None

        # Setting default parameters if not specified
        if error_params is None:
            if self._error_params is None:
                error_params = {'a':0.02, 'b':0.000000001}
            else:
                error_params = self._error_params

        # Check specified Error-Params
        if len(error_params.keys()) > 2:
            raise IOError(f'There are more than 2 error model parameters specified. Please specify parameters, such'
                          f'that they fit the equation [values * slope + offset] or choose another error model')

        # Try to assign parameters based on key names
        if 'slope' in error_params.keys() or 'a' in error_params.keys():
            try:
                _a = error_params['slope']
                _b = [error_params[k] for k in error_params.keys() if k != 'slope'][0]
            except KeyError:
                _a = error_params['a']
                _b = [error_params[k] for k in error_params.keys() if k != 'a'][0]
        if 'offset' in error_params.keys() or 'b' in error_params.keys():
            try:
                _b = error_params['offset']
                _a = [error_params[k] for k in error_params.keys() if k != 'offset'][0]
            except KeyError:
                _b = error_params['b']
                _a = [error_params[k] for k in error_params.keys() if k != 'b'][0]

        # If not already assigned, choose 1st parameter as _a and 2nd as _b
        if _a is None and _b is None:
            k_list =  list(error_params.keys())
            _a     = error_params[k_list[0]]
            _b     = error_params[k_list[1]]

        # Return error if not 0
        error = _a * value + _b

        return error
    

class ParameterMapping(object):

    def __init__(self, local_name:str=None, global_name:str=None, value=0, rid='1'):

        # Check correct input
        if global_name is None:
            raise IOError('No mapping specified, pleas pass at least a global_name as mapping arguments')
        if not isinstance(local_name,str) and local_name is not None:                                                # Check input dtype
            raise TypeError(f'local_name must be a string or None-type, not {type(local_name)}')
        if not isinstance(global_name,str):                                       # Check for wrong keys
            raise TypeError(f'global_name must be string, not {type(global_name)}')
        if not isinstance(rid,str):   # Check Names are String
            raise TypeError(f'The specified replicate ID must be string, not {type(rid)}')
        if not isinstance(value,float) and not isinstance(value,int):
            raise ValueError(f'The specified value must either be float or integer, not {type(value)}')  # Check value is int or float

        #Create Local Name if not provided
        if local_name is None:
            local_name = global_name + '_' + rid
            warn(f'No local name provided. Setting local name to: {local_name}')
        
        # Save mapping
        self.map = {'local_name': local_name, 'global_name':global_name, 'value':value, 'rid': rid}




class Workers:

    def __init__(self, model:Estim8Model, n_workers:int=joblib.cpu_count(), module:str='multiprocessing',
                 start_at:int=1, _msg=True):
        """
        Constructor creates pools for multiprocessing. Each gets an own folder in the working directory, to avoid
        overwriting of simulation files. The original model file is copied into the respective folders and initialized
        sequentially.

        Arguments:
        ----------
            model       : DymolaModel
                Original DymolaModel object to get all necessary information from.

        Keyword Arguments:
        ------------------
            n_workers   : int
                Number of parallelized pools. Default: number of processors on current machine.
            module      : str
                The module to use for parallelization. In case of 'multiprocessing', multiprocessing.Pool(n_workers)
                are initialized on the attribute Workers.pools

        External Functions:
        -------------------
            DymolaModel : load_model()
                          retrieve_variables()
        """
        self._original_model    = model
        self._n_workers         = n_workers
        self._parent_dir        = Path().parent.resolve()
        self._om_path           = Path(model.model_path)
        self._folders           = []
        self._model_paths       = []
        self.pools              = []

        # Creation Message
        if _msg:
            print(f'Creating {n_workers} workers...')

        # Create directories and copy .mo-file
        for worker in range(start_at,self._n_workers+start_at):
            #Create new folders
            _folder_name = 'Worker_' + str(worker)                      # Define sequential names
            _folder_path = self._parent_dir.joinpath(_folder_name)      # Convert to path
            if not os.path.exists(str(_folder_path)):                   # Check if folder already exists
                os.makedirs(str(_folder_path))                          # Create new folder
            else:
                try:
                    shutil.rmtree(str(_folder_path))                    # Else remove old folders and
                    os.makedirs(str(_folder_path))                      # Create new folder subsequently
                except Exception as e:
                    pass
                    #warn(f'Something went while creating folder structure:\n{e}'
                    #                f'Try closing the program and delete folders manually before starting')
            self._folders.append(_folder_path)                          # Save folder path [for deletion afterwards]

            _oe_path = str(self._parent_dir) + '\\' + 'models.py'
            #Copy model to folders
            shutil.copy2(self._om_path, str(_folder_path))              # Copy original model with metadata
            try:
                shutil.copy2(_oe_path, str(_folder_path))  # Copy original model with metadata
            except:
                pass

        if module == 'multiprocessing':
            # Create Pools for calculation
            self.pools = mp.Pool(n_workers)

            # Distribute pools to directories
            self.pools.starmap(self._set_dir, [(self._folders[i],i) for i in range(n_workers)])

        elif module == 'futures':
            pass

        else:
            raise NotImplementedError(f'Workers is not compatible with {module}. Please choose either \'multiprocessing\''
                                      f'or \'futures\'')




    def pickle_args(self, obj_args:dict=None):
        np.save('obj_args.npy', obj_args)
        for f in self._folders:
            np.save(str(f) + '\\' + 'obj_args.npy', obj_args)


    def shutdown(self, kill_dymola=False):

        """
        Deletes the folder structure from the initialization and terminates all Dymola-related processes if specified.
        Requires imported os module. To import add the following statement to your code:

        "import os"

        Keyword Arguments:
        ------------------
            kill_dymola     : boolean
                Executes system command to force shutdown of Dymola.exe if True. Default: False
        """
        if kill_dymola:
            return exec('os.system("taskkill /F /im Dymola.exe")')
        for _dir in self._folders:
            try:
                shutil.rmtree(_dir)
            except:
                warn('folders could not be removed, try manually')


    @staticmethod
    def _set_dir(dir,i):
        os.chdir(dir)
        global worker_id
        worker_id = i

    @staticmethod
    def _create_dymola_model(name,path):
        global dm
        dm = DymolaModel(name,model_path=path)
        dm.initialize(for_est=True)



class LossCalculator(object):

    def __init__(self, metric:str='SS', error_model:ErrorModel=None, error_model_params:dict=None, err_dat_av=False):
        """
        LossCalculator is used to calculate a discrepancy measure between data and simulation. It has two core functions:
            1. get_model_loss(): Calculating the residual
            2. calculate_loss(): Calculating a specified discrepancy measure.

        So far only sum of squares ('SS'), and weighted sum of squares ('WSS') are implemented. The WSS metric requires
        an error for each measured timepoint, which can either be provided by data (NOT YET IMPLEMENTED) or by
        specifying an ErrorModel(), which is passed to the LossCalculator during initialization or via its property:

         LossCalculator.error_model = ErrorModel(error_model_params:dict)

        The parameters for defining the error model, can either be inserted directly when setting up a custom ErrorModel
        class (must inherit ErrorModel), or can be passed as dict. For custom error models, make sure to assign the
        error model parameters unambiguously. By default a LinearErrorModel class is used, with parameters a=0.02, b=eps
        If only error parameters are specified such as:

         LC = LossCalculator(metric='WSS', error_model_params={'a':0.05, 'b':0.1})

        LossCalculator uses the parameters for the linear error model. The keys then must either be 'a' and 'b', or
        'slope' and 'offset'.


        Keyword Arguments:
        ------------------
            metric              : str
                Discrepancy measure to use. Either Sum of squares: 'SS', or weighted sum of squares: 'WSS'. Default:'SS'
            error_model         : ErrorModel
                Error model used for calculating an error for each measurement, when using WSS
            error_model_params  : dict
                Parameters to feed the ErrorModel. In case no error model is specified, use the keys: 'a','b' to assign
                parameters for the LinearErrorModel class

        Raises:
        -------
            UserWarnings:
                Metric not implemented
                WSS but no error model specified
                error model specified and no parameters
                neither error model nor parameters but WSS selected

        """

        # Init Properties / Attributes
        self._observation_mapping = None
        self._error_model         = None
        self._imp_metrics         = ['SS','WSS', 'negLL']

        # Check metric
        if metric in self._imp_metrics:
            self._metric = metric
        else:
            self.metric  = 'SS'
            warn(f'Metric {metric} not implemented. Changed to default (SS)')
            
        # Add Linear Error Model if not specified
        if error_model is None and err_dat_av is False:
            self._error_model = LinearErrorModel(error_params=error_model_params)

            # Warning
            if error_model_params is None:
               #warn(f'Neither error_model nor parameters were specified, Caclulating error as:\n'
               #     f'Error = value * 0.02 + eps')
                self.error_model.error_model_params = {'a': 0.02, 'b': 0.0000001}
            else:
                pass
                #warn(f'No error model specified, trying to use error parameters with linear error model...')


        else:
            self._error_model = error_model

        # Add error_model_params
        if error_model_params:
            self.error_model.error_model_params = error_model_params
            


    @property
    def observation_mapping(self) -> dict:
        return self._observation_mapping

    @observation_mapping.setter
    def observation_mapping(self,values:dict):
        if values is not None and not isinstance(values,dict):
            raise TypeError('observation_mapping must be of type dict')
        self._observation_mapping = values


    @property
    def metric(self):
        return self._metric

    @metric.setter
    def metric(self,values:str):
        if not isinstance(values,str) and values is not None:
            raise TypeError(f'Metric must be string type, not {type(values)}')
        if values not in self._imp_metrics:
            raise NotImplementedError(f'{values} is not implemented. Please choose one of:\n{self._imp_metrics}')
        self._metric = values


    @property
    def error_model(self):
        return self._error_model
    
    @error_model.setter
    def error_model(self, model:ErrorModel):
        self._error_model = model
        



    def get_model_loss(self, exp_data, sim_data:dict, rid:str=None):
        """ 
        Calculates the residual between simulated values and experimental data. In case the observed simulation keys
        differ from the keys of the experimental data, a mapping can be specified to match the keys. To do so, call the
        property setter for observation_mapping and specify a dict sa follows:

            mapping = {
                    'experimental_data_key1' : 'simulation_data_key1'
                    'experimental_data_key2' : 'simulation_data_key2'
                    ...                                               }
            LC = LossCalculator()
            LC.observation_mapping(mapping)

        Arguments:
        ----------
            exp_data : DataFrame / dict[DataFrame]
                Experimental data as pandas DataFrame or as dict over replicates
            sim_data  :  dict
                Simulation data as dict, containing the key "Time" with all timepoints

        Keyword Arguments:
        ------------------
            rid : str
                Replicate ID as string to identify replicate in case of exp_data is dict type

        Raises:
        -------
            IOError :
                exp_data is dict but no replicate ID is specified in
                exp_data neither DataFrame nor dict
            TypeError :
                sim_data must be dict
            AttributeError :
                Time key not in sim_data
            UserWarning :
                Replicate ID specified but exp_data is a DataFrame

        External Functions:
        -------------------
            Utils:
                interpolate_t(data, sim, rid, del_old)
        """
        # Check input
        if isinstance(exp_data,dict) and rid is None:
            raise IOError('If exp_data is passed as dict, a replicate ID must be specified')
        if isinstance(exp_data,pd.core.frame.DataFrame) and rid is not None:
            warn(f'DataFrame was passed and RID is specified. Be sure that the passed DataFrame is replicate {rid}')
        if not isinstance(exp_data,dict) and not isinstance(exp_data,pd.core.frame.DataFrame):
            raise TypeError(f'exp_data must be of type dict or DataFrame, not {type(exp_data)}')
        if not isinstance(sim_data,dict):
            if sim_data is None:
                return np.NaN
            else:
                raise TypeError(f'sim_Data must be dict, not {type(sim_data)}')
        if "Time" not in sim_data.keys():
            raise AttributeError('Key \"Time\" is not in sim_data. Make sure to pass timepoints with this key')

        # Read Input
        _sim        = sim_data
        if isinstance(exp_data,dict):
            _exp    = exp_data[rid]
        else:
            try:
                _exp= exp_data
                _check_df = _exp.index
            except AttributeError:
                raise IOError('exp_data must be Dataframe or dict')
            
        # Interpolate for timepoints of experimental data
        _sim_int    = Utils.interpolate_t(_exp, _sim, rid=rid, del_old=True)
        # Calculate residual
        _residual   = {}
        for _key in _exp.keys():
            if self._observation_mapping is None:
                _residual[_key] = pd.DataFrame(np.abs(np.subtract(_sim_int[_key],_exp[_key].values))).dropna().values
            else:
                try:
                    _residual[_key] = pd.DataFrame(np.abs(np.subtract(_sim_int[self._observation_mapping[_key]],_exp[_key]))).dropna().values# Apply obs mapping
                except KeyError:
                    try:
                        _residual[_key] = pd.DataFrame(np.abs(np.subtract(_sim_int[_key].values(),_exp[_key]))).dropna().values                    # Try orig. keys if fail
                    except KeyError:
                        raise KeyError('Mapping of observations failed. Make sure to specify as "expkey":"simkey"')
        
        return _residual


    def calculate_loss(self, exp_data, sim_data:dict, error_data=None):
        """
        Calculates the objective function value for parameter estimation.

        Methods:
            'SS'    = Sum of squares
            'WSS'   = Weighted sum of squares
            'negLL' = negative Log-Likelihood

        Arguments:
        ----------
            exp_data        : DataFrame
                Experimental data as dict or DataFrame
            sim_data        : dict
                Simulated data as dict.

        Keyword Arguments:
        ------------------
            error_data      : dict / DataFrame
                Errors to use for weighted loss calculation.

        Returns:
        --------
            loss : float
                Loss calculated according method of LossCalculator
        """

        # Get residual
        _residual = self.get_model_loss(exp_data, sim_data)

        # Initialize loss
        loss = 0

        # Choose method
        if self.metric == 'SS':
            # Sum over all replicate ids if specified
            for _key in _residual.keys():
                loss += np.sum(np.square(_residual[_key]))

            return loss


        elif self.metric == 'WSS':
            for _key in _residual.keys():
                if error_data is None:
                    weights = exp_data[_key].apply(lambda x: self._error_model.get_errors(x, self.error_model.error_params)).dropna()
                elif isinstance(error_data, dict):
                    try:
                        weights = pd.DataFrame(error_data[_key], index=exp_data.index).dropna()
                    except Exception as e:
                        raise Exception(e)
                else:
                    weights = error_data[_key].dropna()

                loss   += np.sum(np.square(_residual[_key])/weights.values)

            return loss

        elif self.metric == 'negLL':

            # Interpolate simulation results
            _sim = Utils.interpolate_t(exp_data, sim_data, rid=None, del_old=True)

            # Iterate over measured quantities
            for key in exp_data.keys():
                # Calculate errors
                weights = exp_data[key].apply(lambda x: self._error_model.get_errors(x, self.error_model.error_params)).dropna()
                # Merge simulation data and experimental data for this quantity into one DataFrame
                if key not in _sim.keys():
                    key_s = self.observation_mapping[key]
                else:
                    key_s = key
                try:
                    _sim_df = pd.DataFrame(_sim[key_s], index = exp_data[key].index)
                except KeyError:
                    raise KeyError(f'Something went wrong with the observation mapping. Key {key_s} not available'
                                   f'for simulation dict')
                concat_data = pd.merge(_sim_df, exp_data[key], on=_sim_df.index).dropna()
                concat_data.columns=['Time','sim', 'exp']
                concat_data = concat_data.set_index('Time')
                # Employ error distribution if not priorly defined
                if self._error_model.error_dist is None:
                    self._error_model.error_dist = norm
                # Calculate logpdf function according to pyFOOMB
                errs = []
                for i in concat_data.index:
                    errs.append(-self._error_model.error_dist.logpdf(x=concat_data.loc[i,'sim'],
                                                                 loc=concat_data.loc[i,'exp'],
                                                                 scale=weights[i]))

                loss += np.sum(errs)

            return loss
        else:
            raise NotImplementedError(f'{self.metric} is not a specified method. Use one of: \"SS\", \"WSS\"')



class Estimator(object):
    def __init__(self, model:Estim8Model, bounds:dict, metric:str='SS', data=None, rids:list=None,
                 lc:LossCalculator=None, t:list=None, error_model:ErrorModel=None, error_model_params:dict=None,
                 err_dat:dict=None, observation_mapping:dict=None):
        """
        Estimator is a class for executing parameter estimation or Monte Carlo samplings for a given model of the
        Estim8Model class. Currently this is equal to using a DymolaModel because DymolaModel is the only functional
        Subclass so far.

        Arguments:
        ----------
            model               : Estim8Model
                Model used for executing simulations.
            bounds              : dict
                Boundaries for the parameters to estimate. Parameter estimation is only executed for the parameters
                specified in this dict.

        Keyword Arguments:
        ------------------
            metric              : str
                metric used to instantiate the LossCalculator object.
            data                : dict[DataFrame] / DataFrame
                The experimental data used to measure the model discrepancy. Make sure the dict keys have the same names
                as the replicate IDs (rids). To keep track of RIDs we recommend specifying also the 'rids'-argument
            rids                : list[str]
                Name of replicate IDs, used to match passed data to replicate handling.
            lc                  : LossCalculator
                Custom LossCalculator object for calculating the model discrepancy based on the given data (!! Not
                working with estimate_parallel() method, when using jupyter notebooks)
            t                   : list[float/int]
                Time step definition for simulation in the form: [START, END, STEPSIZE]
            error_model         : ErrorModel
                Custom object inheriting from ErrorModel base class. Calculates error for WSS-metric and sampling range
                for MonteCarlo Sampling
            error_model_params  : dict
                Parameters used in the error model. Make sure the keys match those of the error model (use 'a', 'b' as
                keys for the default LinearErrorModel)
            err_dat             : dict[dict] / dict
                Errors as data in case of specification from measurement. (Overwrites calculated errors!)
            observation_mapping : dict[str]
                Maps the keys from the experimental data to those of the model {'EXP KEY' : 'MODEL KEY',...}



        """
        # Process input
        self.model      = model
        self._program   = model._model_type

        #Define constants
        self._av_metr   = ['SS', 'WSS', 'negLL']
        self._av_opts   = ['de', 'bh', 'local', 'gp']
        self._av_p_opts = ['de', 'gp', 'local']
        self._av_pg_opts= ['pg_de1220', 'pg_abc', 'pg_gaco', 'pg_pso', 'pg_sga', 'pg_sea', 'pg_cs', 'pg_gwo',
                           'gp_cmaes', 'pg_sa', 'pg_nsga2', 'pg_mbh', 'pg_ihs', 'pg_xnes', 'pg_de']

        # Predefine props
        self._rids = None

        # Check available metrics
        if metric in self._av_metr:
            self._metric = metric
        else:
            raise NotImplementedError(f'The metric {metric} is not implemented. please chose one of\n{self._av_metr}')

        #Initialize LossCalculator
        ## LC specified by input
        if lc is not None:
            self._LC = lc
            ## Error Model specified by input
            if error_model is not None:
                self._LC.error_model = error_model
                ### Error Model and error parameters specified
                if error_model_params is not None:
                    self._LC.error_model._error_model_params = error_model_params
            ## Only error parameters specified
            elif error_model_params is not None:
                self._LC.error_model = LinearErrorModel(error_params=error_model_params)
        ## LC not specified
        else:
            self._LC = LossCalculator(metric=metric,
                                      error_model=error_model,
                                      error_model_params=error_model_params)


        # Import Data
        if data is not None:

            # dict-type data
            if isinstance(data,dict):
                if rids is None:
                    ## Creating Replicate IDs from data-keys
                    self._rids = [str(rid) for rid in data.keys()]
                    #warn(f'Data was passed as dict, but no replicate IDs were specified. Please make sure, that your '
                    #     f'replicate names are {self._rids}. Otherwise, import data as pandas.DataFrame.')
                else:
                    ## Check if data-keys match the specified rids
                    dk          = [str(_dk) for _dk in data.keys()]
                    no_match_r  = set(rids) - set(dk)
                    no_match_d  = set(dk) - set(rids)

                    ### Distinguish key not in data and key not in rids
                    if no_match_r != ():
                        warn(f'The specified RIDs {no_match_r} do not match the keys in data.')
                    if no_match_d != ():
                        warn(f'The keys of data {no_match_d} are not in the specified RIDs.')

                ## Assign Data and rids
                self._data = data

            # DataFrame data
            elif isinstance(data,pd.core.frame.DataFrame):
                self._data = data

                ## Check if rids are specified anyways:
                if rids is not None:
                    raise IOError(f'If data is passed as pd.DataFrame, replicate handling is disabled. Please check '
                                  f'your data on:'
                                  f'1. Is my data specified as DataFrame[DataFrame] instead of dict[DataFrame]?'
                                  f'2. Have I imported the entire data from the excel file? If using pandas.read_excel'
                                  f'   specify the argument sheet_name=None')
        ## No data given
        else:
            if rids:   # Still replicates given
                warn(f'No data specified, but replicates {rids} were given. Make sure the data you pass matches '
                     f'these keys')
                self._rids = rids
            else:
                self._rids = None

        #Initialize timepoints
        ## from data
        if (t is None) and (data is not None):
            self._t = Utils.t_from_data(data)        ## Default range
        elif t is None:
            self._t = [0,30,0.2]
            warn(f'No timepoints specified. Timepoints were set to {self._t}\nCall property t to change it.')
        ## Wrong datatype of specified t
        elif not isinstance(t,list):
            raise TypeError(f't must be provided as list, not {type(t)}')
        ## Wrong datatype within list
        elif not Utils.check_dtype(t,(float,int)):
            print(t)
            raise TypeError(f'internals of list t must be integer or float')
        ## Wrong dimension of list
        elif len(t) != 3:
            raise ValueError(f't must be specified as list of 3 entries, containing [t0,t_end,stepsize]')
        ## Valid input
        else:
            self._t = t

        #Initialize properties
        self._bounds                = bounds
        self._data                  = data
        self._err_dat               = err_dat
        self._observation_mapping   = observation_mapping
        self.LC.observation_mapping = observation_mapping
        self._parameter_mapping     = None
        self._worker                = None





    @property
    def t(self):
        return self._t

    @t.setter
    def t(self, t):
        if (t is None) and (self.data is not None):
            self._t = Utils.t_from_data(self.data)
        elif t is None:
            self._t = [0,15,0.1]
            warn(f'No timepoints specified. Timepoints were set to {self._t}\nCall property t to change it.')
        elif not isinstance(t,list):
            raise TypeError(f't must be provided as list, not {type(t)}')
        elif not Utils.check_dtype(t,(int,float)):
            raise TypeError(f'internals of list t must be integer or float')
        elif len(t) != 3:
            raise ValueError(f't must be specified as list of 3 entries, containing [t0,t_end,stepsize]')
        else:
            self._t = t



    @property
    def metric(self):
        return self._metric

    @metric.setter
    def metric(self, value):
        if not isinstance(value,str):
            raise TypeError(f'metric must be of type string, not {type(value)}')
        if value not in self._av_metr:
            raise NotImplementedError(f'The metric {value} is not implemented. please chose one of\n{self._av_metr}')
        self._metric = value
        if self._LC is not None:
            self._metric = value
            self._LC.metric = value


    @property
    def rids(self):
        return self._rids

    @rids.setter
    def rids(self, values:list):
        if not isinstance(values,list):
            raise TypeError(f'rids must be in the form of al list, not {type(values)}')
        if not Utils.check_dtype(values,str):
            raise ValueError(f'rids in the list must be of type string')
        self._rids = values


    @property
    def LC(self):
        return self._LC

    @LC.setter
    def LC(self, values:LossCalculator):
        if not isinstance(values, LossCalculator):
            raise TypeError(f'LC must be a LossCalculator object, not {type(values)}')
        self._LC = values


    @property
    def data(self) -> dict:
        return self._data

    @data.setter
    def data(self, values):
        if not isinstance(values,dict) and not isinstance(values,pd.core.frame.DataFrame):          # Check dtypes
            raise TypeError(f'data must either be of type dict or pandas DataFrame, not {type(values)}')
        if isinstance(values,dict):                                                                 # Check rids if dict
            if self.rids == None:
                warn(f'No replicate IDs have been specified. Changing rids to {values.keys()}\n To change replicate IDs'
                     f'call <Estimator-Object>.rids = [new rids]')
                self.rids = [str(r) for r in values.keys()]
            elif list(set(values.keys()) - set(self.rids)) != []:
                warn(f'Some replicate IDs do not match the keys of the provided dict: '
                     f'{list(set(values.keys()) - set(self.rids))}')
                self._data = values
            else:
                self._data = values
        if isinstance(values,pd.core.frame.DataFrame):                                              # Make dict if df
            warn('DataFrame was converted into dict with key \"1\"')
            self._data = {'1':values}


    @property
    def err_dat(self):
        return self._err_dat

    @err_dat.setter
    def err_dat(self, data):
        if not isinstance(data, dict) and not isinstance(data, pd.core.frame.DataFrame):
            raise TypeError(f'err_dat must either be dict or DataFrame, not {type(data)}')
        #if self._data is not None:
        #    if isinstance(data, dict):
        #        if [str(k1) for k1 in data.keys()] not in [str(k2) for k2 in self.data.keys()]:
        #            raise AttributeError(f'keys of the error data do not match those of the experimental data\n '
        #                                 f'data keys: {self.data.keys()}\n '
        #                                 f'error keys: {data.keys()}')
        #        _key1 = [k for k in data.keys()][0]
        #        if len(data[_key1].index) != len(self.data[_key1].index):
        #            raise IndexError(f'The length of the index does not match the length of index from the experimental '
        #                             f'data')
        #if isinstance(data, dict) and self.rids is None:
        #    raise IOError('Error data is dict type, but no replicates are specified. Either set replicates or pass error'
        #                  'data as pandas.DataFrame')
        self._err_dat = data

    @property
    def bounds(self):
        return self._bounds

    @bounds.setter
    def bounds(self, values:dict):
        if values is not None and not isinstance(values,dict):                                              #check dict
            raise TypeError(f'bounds must be in the form of dict, not {type(values)}')
        for _key in values.keys():
            if not isinstance(values[_key],list) and not isinstance(values[_key],tuple):                    #check list/tuple
                raise TypeError(f'Dict of bounds must contain a list or tuple with lower and upper bound')
            else:
                if not Utils.check_dtype(values[_key],(float,int)):   #check int/float
                    raise TypeError(f'One or more values inside bounds are not of type float or integer')
                if (_key not in self.model.parameters.keys()) and (self.model.parameters is not None):             #check key
                    if self.parameter_mapping is not None:
                        li = []
                        for rid in self.parameter_mapping.keys():
                            for k in self.parameter_mapping[rid].keys():
                                li.append(str(k))
                        if _key not in li: # check local keys
                            raise KeyError(f'The key {_key} is not specified as parameter. '
                                        f'Parameters are:\n{self.model.parameters.keys()}{li}')
                    else:
                        raise KeyError(f'The key {_key} is not specified as parameter. '
                                       f'Parameters are:\n{self.model.parameters.keys()}')
                if self.model.parameters is None:
                    warn("Bounds are being specified before assignment of parameters. Errors in keys might not be tracked...")
                if values[_key][0] >= values[_key][1]:                                                      #check lb<ub
                    raise ValueError(f'lower bound value  for {_key} {values[_key][0]} is greater or equal upper bound value {values[_key][1]}')
        self._bounds = values

    @property
    def observation_mapping(self):
        return self._observation_mapping
    
    @observation_mapping.setter
    def observation_mapping(self, values:dict):
        # Use setter of LossCalculator
        self._LC.observation_mapping    = values
        self._observation_mapping       = values

    @property
    def parameter_mapping(self) -> dict:
        return self._parameter_mapping

    @parameter_mapping.setter
    def parameter_mapping(self, mapping):
        if not isinstance(mapping,list) and not None:
            raise TypeError(f'Parameter mapping must be a list, not {type(mapping)}')
        if not Utils.check_dtype(mapping, ParameterMapping):
            raise TypeError(f'List entries must be of type ParameterMapping')
        
        # Initialize mapping for all RIDs
        _pmap   = {}
        for _rid in self.rids:
            _pmap[_rid] = {}

        for m in mapping:
            if m.map['rid'] not in self._rids:
                raise ValueError(f'The replicate ID {m["rid"]} is not in the specified replicate list:\n{self._rids}')
            _pmap[m.map["rid"]].update({
                m.map["local_name"] : {
                    "global_name"   : m.map["global_name"],
                    "value"         : m.map["value"]
                }
            })
        self._parameter_mapping = _pmap


    @property
    def worker(self):
        return self._worker

    @worker.setter
    def worker(self, workers):
        if (workers is not None) and not isinstance(workers,Workers):
            raise TypeError(f'Pools must either be of class Workers or None')
        self._worker = workers

    def show_parameter_mapping(self):
        """
        Pretty printing of ParameterMapping. Reorders the parameter_mapping property such that a DataFrame with local
        names as index and all related other attributes as columns appear. Using IPython.display for printing.
        """
        _df_like = {}
        ind =  []
        _df_like["global_name"] = []
        _df_like["RID"]         = []
        _df_like["value"]       = []
        for rid in self._parameter_mapping.keys():
            for local_name in self._parameter_mapping[rid].keys():
                ind.append(local_name)
                _df_like["global_name"].append(self._parameter_mapping[rid][local_name]["global_name"])
                _df_like["RID"].append(rid)
                _df_like["value"].append(self._parameter_mapping[rid][local_name]["value"])
        _df = pd.DataFrame(_df_like, index = ind)
        display(_df)


    def obj(self, par:list):
        """
        Calculating an objective function for the model (property) of Estimator, using its LossCalculator LC
        (property), to return the loss to the optimizer.

        Arguments:
        ----------
            par : list[float]
                current parameters as a list of floats in the order of parameters specified.

        External Functions:
        -------------------
            simulate()      : DymolaModel
                Retrieving simulation results for given parameter set
            calculate_loss  : LossCalculator
                Calculating loss between experimental data and simulation results
            replicate_handling : Utils
                distinguishes the parameters according to the specified parameter_mapping by creating 3 lists for
                distinguishing the 4 cases (replicate ID handling):
                    1. The parameter is global and not estimated
                    2. The parameter is global and estimated        -> _glob_est
                    3. The parameter is local and not estimated     -> _loc_par
                    4. The parameter is local and estimated         -> _loc_est

        Returns:
        --------
            Loss : float
                The Loss calculated by the metric provided to the estimator class
        """

        # Initialize list of parameter values and Loss
        loss = 0

        # Create list of bound keys (=estimated parameters) and parameter keys (=all global parameters)
        bnd_k       = [str(key) for key in self.bounds.keys()]              # local keys of boundaries specified
        par_k       = [str(key) for key in self.model.parameters.keys()]    # global parameter keys

        #Iterate over replicates
        if self._rids is not None:
            for rid in self._rids:

                # Handle replicates
                _par = Utils.replicate_handling(par, bnd_k, par_k, self.model.parameters, rid, self._parameter_mapping)

                # Simulate
                sim, _ = self.model.simulate(self.t[0], self.t[1], self.t[2], parameters=_par, for_est=True)

                # Compare
                if self.err_dat is None:
                    loss += self.LC.calculate_loss(self.data[rid], sim, None)
                else:
                    loss  += self.LC.calculate_loss(self.data[rid], sim, self.err_dat[rid])

        else:
            # Convert parameter list to dict
            _par    = Utils.replicate_handling(par, bnd_k, par_k, self.model.parameters, None, self._parameter_mapping)

            # Simulate
            sim, _  = self.model.simulate(self.t[0],self.t[1],self.t[2], parameters=_par, for_est=True)

            # Compare
            loss    = self.LC.calculate_loss(self.data, sim, self.err_dat)

        return loss






    def estimate(self, method:str, bounds:dict=None, metric:str=None, p0:dict=None, max_iter:int=None,
                 optimizer_kwargs:dict=None):
        """
        Estimates a set of parameters for the model provided to estimator based on the data specified in Estimator.

        Arguments:
        ----------
            method : str
                Optimization method to be used
                    'local'     = Scipy minimize using either Nelder-Mead or Powell
                    'de'        = Differential Evolution
                    'bh'        = Basinhopping

        Keyword Arguments:
        ------------------
            bounds : dict
                Boundaries for the parameters to estimate. For fixing parameters equalize bounds
            metric : str
                Used metric to calculate loss. (Default by LossCalculator.calculate_loss() is sum of squares)
            p0     : dict
                Initial values for parameter estimation
            max_iter         : int
                Maximum number of iterations
            optimizer_kwargs : dict
                Additional specifications to be passed to the selected optimizer

        External Functions:
        -------------------
            optimize : Optimization
                Minimizing an objective function.

        Raises:
        -------

        Returns:
        --------
            res         : dict
                Best fit parameters
            info        : scipy.optimization.result (-> dict)
                Information about estimation provided by scipy
        """
        # Check method
        if method not in self._av_opts:
            raise NotImplementedError(f'The solver {method} is not implemented. Chose one of:\n{self._av_opts}')

        # Check bounds by passing them to the Estimator
        if bounds is None:
            bounds      = self.bounds
        elif self.bounds is not None:
            self.bounds = bounds

        # Check metric by passing it to the Estimator
        if metric is None:
            metric      = self.metric
        else:
            self.metric = metric                                                    # by default 'SS' / see __init__()

        # Include maximum number of iterations
        if max_iter is not None:
            optimizer_kwargs = Utils.insert_maxiter(method, max_iter, optimizer_kwargs)

        # Ensure initial point
        if (p0 is None) and (method in ['local', 'bh']):
            if self.parameter_mapping is None:
                p0 = Utils.get_initial_point(self.model.parameters, bounds, 'model')
            else:
                p0 = Utils.get_initial_point(self.model.parameters, bounds, 'model', parameter_mapping=self.parameter_mapping)

        # Initialize Optimization
        optimization = Optimization()
        print(f'\nStarting estimation using {method} for parameters:\n{list(bounds.keys())}')
        res, info = optimization.optimize(
            self.obj,
            method  = method,
            bounds  = bounds,
            p0      = p0,
            opt_kw  = optimizer_kwargs,
        )

        return res, info


    def estimate_parallel(self, method, n_workers:int, bounds:dict=None, metric:str=None, p0:dict=None,
                          optimizer_kwargs:dict=None, report:bool=True, backup=True, max_iter:int=None, _msg=True):
        """
        Method for parallel optimization of evolutionary algorithms, executing the calculation of population in a
        parallel manner. Uses the Workers() class to create a folder structure within the current working directory,
        to avoid overwriting result- and Dymola-native files. The instantiated ProcessPools are then passed to scipy's
        optimization method in the keyword workers.
        Converts all data required for calculation of objective() into Python-native data formats (must be pickleable)
        and passes it to the function via the args keyword.
        After the results are generated, all Dymola processes are killed and the folder structure is deleted.

        Arguments:
        ----------
            method      : str/list[str]
                Optimization method to be used / List of algorithms for archipelago approach:
                For standard parallelization:
                    'gp'        = Bayesian optimization using Gaussian processes (scikit-optimize)
                    'de'        = Differential Evolution (scipy)
                For Archipelago:
                    'pg_cs'     = compass search (local solver)
                    'pg_de1220' = Self-adaptive differential evolution
                    'pg_abc'    = Bee Colony Optimization
                    'pg_gaco'   = Ant Colony Optimization
                    'pg_pso'    = Particle Swarm Optiization
                    'pg_sga'    = Simple Genetic Algorithm
                    'pg_sea'    = Sequential Evolutionary Algorithm
                    'pg_gwo'    = Grey Wolf Optimization
                    'gp_cmaes'  = Covariance Matrix Evolution Strategy
                    'pg_sa'     = Simulated Annealing
                    'pg_nsga2'  = Non dominated Sorting Genetic Algorithm
                    'pg_mbh'    = Monotonic Basin Hopping
                    'pg_ihs'    = Improved Harmony Search
                    'pg_xnes'   = Exponential Evolution Strategies

        Keyword Arguments:
        ------------------
            bounds : dict
                Boundaries for the parameters to estimate. For fixing parameters equalize bounds
            metric : str
                Used metric to calculate loss. (Default by LossCalculator.calculate_loss() is sum of squares)
            p0     : dict
                Initial values for parameter estimation
            optimizer_kwargs : dict
                Additional specifications to be passed to the selected optimizer
            report : bool
                Enables report after evolutions of Archipelagos. This slows down the calculations because it needs to
                Synchronize all islands after every evolution.
            max_iter: int
                Maximum number of iterations

        External Functions:
        -------------------
            Workers().__init__() : estim8.py
                Creating folder structure
            optimize             : optimization.py
                Minimizing loss
            Utils.insert_maxiter : utils.py
                Inserting iteration number to optimizer keywords

        Raises:
        -------
            NotImplementedError :
                If method is not known or not meant to be parallelized
        """

        if isinstance(method, list):
            if len(method) != n_workers:
                warn(f'Archipelago approach has been chosen, but list of algorithms does not match n_workers. '
                     f'Continuing with {len(method)} workers')
            self.worker = Workers(self.model, n_workers=len(method), module='futures', _msg=_msg)
        elif n_workers > 0:
            self.worker      = Workers(self.model, n_workers=n_workers, _msg=_msg)

        if isinstance(method,str):
            if method not in self._av_p_opts:
                if not method.startswith('pg'):
                    if method in self._av_opts:
                        raise NotImplementedError(f'The method {method} is not available for parallel calculations')
                    else:
                        raise NotImplementedError(f'The method {method} is not available')
                else:
                    if method not in self._av_pg_opts:
                        raise NotImplementedError(f'The method {method} is not available')
        elif isinstance(method, list):
            for alg in method:
                if not isinstance(alg,str):
                    raise TypeError(f'An entry of methods is not of type string, but {type(alg)}')
                elif alg not in self._av_pg_opts:
                    if (alg in self._av_opts) or (alg in self._av_p_opts):
                        raise NotImplementedError(f'{alg} is not available for Archipelago approach. Try another '
                                                  f'algorithm from: \n {self._av_pg_opts} or pass it as string to '
                                                  f'optimize conventionally')
                    else:
                        raise NotImplementedError(f'{alg} is not available as optimizer')

        if backup and not report:
            warn(f'Backup not possible if report option is false')

        # Check bounds by passing them to the Estimator
        if bounds is None:
            bounds      = self.bounds
        elif self.bounds is not None:
            self.bounds = bounds

        # Check metric by passing it to the Estimator
        if metric is None:
            temp_met    = None
            metric      = self.metric
        else:
            temp_met    = self.metric
            self.metric = metric
        # Provide initial guess if not specified and required
        if isinstance(method,str):
            if (p0 is None) and (method in ['local', 'bh']):
                if self.parameter_mapping is None:
                    p0 = Utils.get_initial_point(self.model.parameters, bounds, 'model')
                else:
                    p0 = Utils.get_initial_point(self.model.parameters, bounds, 'center', parameter_mapping=self.parameter_mapping)

        _main_folder = str(Path().parent.resolve())

        # Prepare Arguments
        ## Get LC name for enabling custom LossCalculators
        LC_name             = Utils.get_class_name(self.LC)
        ## Get calling program name for LC import
        if LC_name != 'LossCalculator':
            scr_name            = Utils.get_calling_script_name(inspect.stack())
        else:
            scr_name            = None
        ## Check for Error Model
        if isinstance(self.LC.error_model, LinearErrorModel) and self.err_dat is None:
            err_par = self.LC.error_model.error_params
        elif self.LC.error_model and self.err_dat is None:
            self.err_dat = Utils.generate_errors(self.data, self.LC.error_model.get_errors, self.LC.error_model._error_params, rids=self.rids)
            err_par = self.LC.error_model._error_params
        else:
            err_par = None

        # CALL UTILS TO PACK ARGUMENTS
        args2pass   = Utils.pack_args(self, err_par=err_par, LC_name=LC_name, scr_name=scr_name)

        # Add number of workers to optimizer kwargs
        if max_iter is not None:
            if isinstance(method,list):
                optimizer_kwargs = Utils.insert_maxiter(method[0], max_iter, optimizer_kwargs)
            else:
                optimizer_kwargs = Utils.insert_maxiter(method, max_iter, optimizer_kwargs)
        elif optimizer_kwargs is None:
            optimizer_kwargs = {}


        # Start Optimization
        optimization = Optimization()
        if _msg:
            print(f'\nStarting estimation using {method} on {n_workers} workers for parameters:\n{list(bounds.keys())}')
        if not isinstance(method,list):
# Dymola DE
            if method in ['de'] and self.model._model_type == 'dymola':
                # Set workers
                optimizer_kwargs['workers'] = self.worker.pools.map
                optimizer_kwargs['updating']= 'deferred'

                # Estimate
                res, info = optimization.optimize(
                    objective,
                    method= method,
                    bounds= bounds,
                    args  = args2pass,
                    p0    = p0,
                    opt_kw= optimizer_kwargs,
                    mcs   = not _msg,
                )

# Dymola GP
            elif method in ['gp', 'forest']:

                # Set workers
                optimizer_kwargs['n_jobs'] = n_workers

                # Store args in dict
                args4obj = {'mod_name': args2pass[0], 'folders': args2pass[1], 'rids': args2pass[2],
                            'parameter_mapping': args2pass[3], 't': args2pass[4], 'bounds': args2pass[5],
                            'data': args2pass[6], 'obs_map': args2pass[7], 'metric': args2pass[8],
                            'err_par':args2pass[9],'err_dat':args2pass[10], 'LC_name':args2pass[11],
                            'scr_name':args2pass[12], 'mod_type':self.model._model_type,
                            'par_defaults':args2pass[13]}

                # Save to folders
                try:
                    self.worker.pickle_args(args4obj)

                    res, info = optimization.optimize(
                        objective_sk,
                        method=method,
                        bounds=bounds,
                        p0=p0,
                        opt_kw=optimizer_kwargs,
                        mcs = not _msg,
                    )
                except:
                    args4obj['scr_name']='no'
                    warn(f'Calling script could not be identified. If using custom LossCalcualtors make sure to run the'
                         f' Skript from a .py file')
                    self.worker.pickle_args(args4obj)

                    res, info = optimization.optimize(
                        objective_sk,
                        method=method,
                        bounds=bounds,
                        p0=p0,
                        opt_kw=optimizer_kwargs,
                        mcs=not _msg,
                    )
# Pygmo
            elif method.startswith('pg'):

                # Add model type argument
                args2pass += (self.model._model_type,)

                # Define pygmo problem
                problem    = pg.problem(PygmoProblem(*args2pass))

                res, info  = optimization.optimize_pygmo(
                    problem,
                    method      = method,
                    opt_kw      = optimizer_kwargs,
                    backup      = backup,
                    _msg        = _msg,
                )

                # Convert to dict
                res = dict(zip(list(self.bounds.keys()), res))
# FMU DE
            else:
                # Set workers
                optimizer_kwargs['workers'] = self.worker.pools.map
                optimizer_kwargs['updating'] = 'deferred'

                # Estimate
                res, info = optimization.optimize(
                    objective_fmu,
                    method=method,
                    bounds=bounds,
                    args=args2pass,
                    p0=p0,
                    opt_kw=optimizer_kwargs,
                    mcs = not _msg,
                )

# Pygmo Archipelago
        else:

            # Add report to kwargs
            optimizer_kwargs['report'] = report

            # Add model type argument
            args2pass += (self.model._model_type,)

            # Define pygmo problem instance
            problem = pg.problem(PygmoProblem(*args2pass))

            # Call Archipelago Optimization
            res, info = optimization.optimize_pygmo_archi(
                problem,
                method=method,
                opt_kw=optimizer_kwargs,
                backup=backup,
                _msg  = _msg,
            )

            # Convert to dict
            if res is not None:
                res = dict(zip(list(self.bounds.keys()), res))

        # After Estimation
        os.chdir(_main_folder)
        if not isinstance(method, list) and (n_workers>0):
            self.worker.pools.close()
            self.worker.pools.join()
            self.worker.pools.terminate()
            self.worker.shutdown()
        self.worker = None

        if temp_met is not None:
            self.metric = temp_met

        return res, info


    def mc_sampling(self, method='local', n_samples:int=100, mcs_at_once:int=1, bounds:dict=None,
                    evos2reset:int=1, error_dist:rv_continuous=norm, error_model:ErrorModel=None, tot_iter:int=400,
                    em_pars:dict=None, dist_pars:dict=None, optimizer_kwargs:dict=None, save_every:int=1,
                    p0:dict=None, no_progress:int=np.inf, final_loss:float=0.0, del_temp:bool=False):
        """
        Executes a Monte Carlo Sampling over the experimental data using concurrent.futures for parallelization.

        Keyword Arguments:
        ------------------
            method      : str / list
                Optimization method to apply for parameter estimation
            n_samples   : int
                Number of Monte Carlo samples
            mcs_at_once   : int
                Number of CPU-cores to use
            bounds      : dict
                Bounds for parameter estimation
            evos2reset  : int
                Allowed Evolutions until RAM is filled
            error_dist  : scipy.stats.rv_continuous
                Distribution function for resampling dataset specified in Estimator attribute data.
            error_model : ErrorModel
                Model for defining the error range for the dataset
            tot_iter    : int
                Maximum total iterations allowed per sample (can be used as Termination criterion)
            em_pars     : dict
                Parameters for the error model
            dist_pars   : dict
                Parameters for distribution of sampling.
            optimizer_kwargs : dict
                Keywords for Optimization.optimize()
            no_progress  : int
                Maximum number of iterations without progress (in a row) until termination
            final loss   : float
                Loss, below which the estimation is terminated
        """

        _main_folder = str(Path().parent.resolve())
        _tstart      = time.time()

        # Check for changes in calculation parameters

        ## Bounds
        if bounds is not None:
            bnds        = bounds
        else:
            bnds        = self.bounds

        ## Optimizer_kwargs
        if optimizer_kwargs is None:
            optimizer_kwargs = {}

        ## Initial guess
        if not isinstance(method,list):
            if method in ['local', 'bh']:
                if self.parameter_mapping is None:
                    p0 = Utils.get_initial_point(self.model.parameters, bnds, 'model_random')
                else:
                    p0 = Utils.get_initial_point(self.model.parameters, bnds, 'bnd_random', parameter_mapping=self.parameter_mapping)
            else:
                p0 = p0

        ## Maxiter default
        if not isinstance(method, list):
            if 'maxiter' not in optimizer_kwargs.keys() and 'n_evo' not in optimizer_kwargs.keys() \
                and 'niter' not in optimizer_kwargs.keys():
                if optimizer_kwargs is None:
                    optimizer_kwargs = {}
                if method == 'de':
                    optimizer_kwargs['maxiter'] = tot_iter
                elif method == 'bh':
                    optimizer_kwargs['niter']   = tot_iter
                elif method.startswith('pg'):
                    optimizer_kwargs['n_evo']   = tot_iter
        else:
            optimizer_kwargs['n_evo'] = evos2reset



        ## Error model
        ## Confirm Error model available
        if error_model is not None:
            self.LC.error_model                         = error_model
        elif self.LC.error_model is None:
            self.LC.error_model                         = LinearErrorModel()
        ## Use error parameters if specified
        if em_pars is not None:
            self.LC.error_model.error_params            = em_pars
        elif self.LC.error_model.error_params is None:
            self.LC.error_model.error_params            = {'a':0.02, 'b':0.00001}
        ## Use error distribution
        if error_dist is not None:
            self.LC.error_model.error_dist              = error_dist            # Required to avoid reset of dist_pars
        ## Set error distribution parameters if available
        if dist_pars is not None:
            try:
                self.LC.error_model.error_dist.rvs(**dist_pars)
                self.LC.error_model.dist_pars = dist_pars
            except Exception as e:
                raise AttributeError(f'Distribution parameters do not match:\n{e}')


        # Resample data
        resampling = []
        for mcs in range(n_samples):
            tmp_dat = {}         # Temporary dict to collect rids
            if self.rids is not None:
                for rid in self.data.keys():
                    tmp_dat[rid] = self.LC.error_model.get_sampling(self.data[rid], self.LC.error_model.error_params)
            else:
                tmp_dat          = self.LC.error_model.get_sampling(self.data,      self.LC.error_model.error_params)
            resampling.append(tmp_dat)                                              # Collect all data in list


        # Instantiate Workers
        ## Create workers for samples at once
        self.worker = Workers(self.model, mcs_at_once, module='futures')
        ## Create counter to pass correct folder
        fol_list=[]
        for f in range(len(resampling)):
            if f < mcs_at_once:
                fol_list.append(self.worker._folders[f])
            else:
                fol_list.append(self.worker._folders[f%mcs_at_once])

        # Print Message
        Messages.m_mc_sample_start(n_samples, tot_iter, evos2reset, no_progress, final_loss)

        # Setup argument list
        if isinstance(method,list):
            i = 0
            args2pass = []
            for dat in resampling:
                args2pass.append(
                    (self.model.name,
                     fol_list[i],
                     self.parameter_mapping,
                     self.t,
                     bnds,
                     self.rids,
                     dat,
                     self.LC.observation_mapping,
                     self.metric,
                     optimizer_kwargs,
                     method,
                     self.LC.error_model.error_params,
                     p0,
                     self.err_dat,
                     Utils.get_class_name(self.LC),
                     None,#Utils.get_calling_script_name(inspect.stack()),
                     self.model._model_type,
                     self.model.parameters,
                     tot_iter,
                     no_progress,
                     final_loss,
                     evos2reset,
                     )
                )
                i+=1
        else:
            args2pass   = [
                (self.model.name,
                 self.worker._folders,
                 self.parameter_mapping,
                 self.t,
                 bnds,
                 self.rids,
                 dat,
                 self.LC.observation_mapping,
                 self.metric,
                 optimizer_kwargs,
                 method,
                 self.LC.error_model.error_params,
                 p0,
                 self.err_dat,
                 Utils.get_class_name(self.LC),
                 None, #Utils.get_calling_script_name(inspect.stack()),
                 self.model._model_type,
                 self.model.parameters,
                 tot_iter,
                 no_progress,
                 final_loss,
                 evos2reset,
                )
                for dat in resampling
            ]

        # Initialize results
        res_mc = []


        # Use concurrent futures to parallelize calculations TODO: Enable for more than 1 worker
        with futures.ProcessPoolExecutor(max_workers=mcs_at_once) as ex:
            results = [ex.submit(_mc_estimate, arg) for arg in args2pass]

            # Initialize counters
            n_mcs, n_bat, t0   = 1, 1, _tstart

            # Gather results

            ## Init auxiliary vars for backup generation
            save_count = 0
            _bkp_nr    = 0
            _init      = False
            for _res in results:

                # Read and append results
                res_mc.append(_res.result())

                # Increase counter
                save_count += 1

                # Backup creation
                if (save_count%save_every) == 0:
                    _bkp_nr   = Utils.mc_backup(res_mc,init=_init,file_nr=_bkp_nr)
                    _init = True

                # Print Callback Message
                print(f'---- Sample {len(res_mc)} completed')

                # Delete Temp Files
                if del_temp:
                    Utils.clear_temp_data(warning=False)


        os.chdir(_main_folder)
        self.worker.shutdown()


        return res_mc



    def estimate_archi_continued(self, info, n_evo:int, report=True, backup=True, clear_temp:bool=False, _msg=True):
        """
        Continues a PyGMO Archipelago estimation by using the archi attribute from the previous estimation. The entire
        estimation is executed in this method and no "optimization" module is called.

        Arguments:
        -----------
            info        : dict
                Estimation info dict from previous estimation run.
            n_evo       : int
                Number of additional evolutions to execute

        Keyword Arguments:
        ------------------
            report      : bool
                Set true for continuous output of results (after every evolution)
            backup      : bool
                Creates excel backup files of the current best parameter set during optimization (overwritten once a new
                best is found)
            clear_temp  : bool
                Clears temporary files after executing this method. Removes also the FMU model of the main thread, so if
                active no plotting and simulating is possible until the program restart


        External Functions:
        -------------------
            Messages.m_pygmo_results()  : utils.py
                Prints message after finishing the estimation (Best loss and total estimation time)
            Messages.m_pygmo_evoultion(): utils.py
                Prints current loss and time for calculation of current evolution

        Returns:
        --------
            res         : dict
                Decision vector of estimation
            info_new    : dict
                Updated estimation info, including used archipelago.
        """
        # Extract Archipelago & No. Evolution
        archi       = info['archi']
        n_evo_old   = info['n_evo']

        # Reboot Island Pools
        for island in archi:
            island.extract(pg.mp_island).init_pool(len(archi))

        # Initialize time measurement
        start_time = time.time()

        # Init check for backup
        _init = False
        _bkp_nr = 0

        # Estimation as in optimization.optimize_archi
        if report:
            for evo in range(n_evo_old+1,n_evo+n_evo_old+1):
                t0 = time.time()
                archi.evolve(1)
                archi.wait()
                if _msg:
                    Messages.m_pygmo_evolution(evo,min(archi.get_champions_f())[0],time.time()-t0)
                if backup:
                    y = archi.get_champions_f()
                    x = archi.get_champions_x()
                    champ_id = y.index(min(y))
                    _bkp_nr = Utils.pg_backup(archi.get_champions_x()[champ_id], init=_init, file_nr=_bkp_nr,
                                              bound_keys=list(self.bounds.keys()))
                    _init = True
        else:
            # Evolve Archipelago at once
            archi.evolve(n_evo)
            Utils.pg_backup(archi.get_champions_x(), init=_init, file_nr=_bkp_nr)

        #Synchronize
        archi.wait()

        # Save champions
        y = archi.get_champions_f()
        x = archi.get_champions_x()
        champ_id = y.index(min(y))

        # Create info dict
        info_new = {
            'fun': y[champ_id][0],
            'time': str( (time.time() - start_time)) + ' min',
            'n_evo': n_evo+n_evo_old,
            'archi' : archi,
        }

        # Append names of decision vector
        res = dict(zip(list(self.bounds.keys()), x[champ_id]))

        # Print message after completion
        if _msg:
            Messages.m_pygmo_result(info_new['fun'],start_time)

        # Shutdown pools
        for island in archi:
            island.extract(pg.mp_island).shutdown_pool()

        # Clear temporary files from disk if set
        if clear_temp:
            Utils.clear_temp_data(warning=False)

        return res, info_new


    def profile_likelihood(self, p_opt:dict, n_points:int=3, dp_rel:float=0.1, p_inv:list=None,
                           bounds:dict=None, disp=True, method='local', p_at_once:int=None, evos2reset:int=1,
                           seed:int=10, opt_kw:dict=None, tot_evo:int=300):
        """
        Calculating Profile Likelihood to determine parameter indentifiablility, for the given parameters to
        investigate. All investigated parameters are fixed and varied within the specified relative range of dp_rel.
        Then an estimation for determining the other parameters is carried out. In case the investigated parameter is
        identifiable, a varying and fixing the parameter will cause a higher loss. In the case of non-identifiable
        parameters, the same loss (or only slightly higher loss) is reached. The user is encouraged to envestigate the
        resulting plots, which are drawn if the "disp" argument is True. The plot should appx. look like:


        IDENTIFIABLE
        ^
        |  XXXX                    XXXX
        |  XXXX              XXXX  XXXX
        |  XXXX  XXXX        XXXX  XXXX
        |  XXXX  XXXX        XXXX  XXXX
        |  XXXX  XXXX        XXXX  XXXX
        |  XXXX  XXXX  XXXX  XXXX  XXXX
        |  XXXX  XXXX  XXXX  XXXX  XXXX
        |  XXXX  XXXX  XXXX  XXXX  XXXX
        |--------------------------------->

        NON-IDENTIFIABLE
        ^
        |
        |
        |
        |
        |  XXXX  XXXX  XXXX  XXXX  XXXX
        |  XXXX  XXXX  XXXX  XXXX  XXXX
        |  XXXX  XXXX  XXXX  XXXX  XXXX
        |--------------------------------->

        Arguments:
        ----------
            p_opt       : dict
                Decision vector of the best estimation result
            n_points    : int
                Number of points in between the specified bounds to investigate. Increase if results are ambiguous.
            dp_rel      : float > 0
                Relative span of parameter variations to investigate (applied on p_opt[p_inv])

        Keyword Arguments:
        ------------------
            p_inv       : list
                List of parameter keys to investigate for identifiaboility. If None: all parameters
            bounds      : dict[list]
                Adjusted bounds for evaluation. Increase if high dp_rel is used.
            disp        : bool
                Decides if the results are plotted after completion
            method      : str
                Optimization method to use (parallelization only via Archipelago)
            p_at_once   : int
                Number of parameter evaluations to execute simultaneously
            evos2reset  : int
                Number of evolutions until archipelago is reset
            seed        : int
                Random seed to use for MGO
            opt_kw      : dict
                Optimizer Keyword arguments to pass
            tot_evo     : int
                Total number of iterations

        External Functions:
        -------------------
            _profile_likelihood_disp()      : estim8.py
                Plotting results of calculated profile likelihood
            _profile_likelihood_calc()      : estim8.py
                Calculation method for profile likelihood executed for each investigated parameter
            Workers.__init__()              : estim8.py
                Generation of parallelization framework


        Returns:
        --------
            res_t       : dict
                Dict of all losses for each parameter and variation percentage.

        """

        # Check input

        ## bounds
        if bounds is None:
            bounds = self.bounds
        elif set([str(k) for k in bounds.keys()]) - set([str(k2) for k2 in self.bounds.keys()]):
            raise KeyError(f'The bound names {set([str(k) for k in bounds.keys()]) - set([str(k2) for k2 in self.bounds.keys()])}'
                           f'are not specified in the bound property of this Estimator. To continue anyways change the'
                           f'property of the Estimator.')
        if p_inv is None:
            p_inv = [str(k) for k in self.bounds.keys()]

        ## dp_rel
        if (dp_rel > 1) or (dp_rel <=0):
            raise ValueError(f'Relative parameter variation width dp_rel must be in (0,1]')

        ## p_inv
        _par_names = [str(k) for k in self.bounds.keys()]
        if set(p_inv) - set(_par_names):
            raise KeyError(f'The parameter names {set(p_inv) - set(_par_names)} are not specified in the bounds')



        ## p_opt
        if set([str(k) for k in p_opt.keys()]) - set([str(k2) for k2 in self.bounds.keys()]):
            raise KeyError(
                f'The bound names {set([str(k) for k in p_opt.keys()]) - set([str(k2) for k2 in self.bounds.keys()])}'
                f'are not specified in the bound property of this Estimator.')

        ## method
        n_jobs   = 1
        parallel = False                              # Decide on estimation method of Estimator()
        if not isinstance(method,list):
            if str(method).startswith('pg_'):         # use for single core PyGMO algos
                parallel = True
        else:
            parallel = True                           # Archipelago approach
            n_jobs   = len(method)                    # Define Archipelago size

        ## p_at_once
        if p_at_once is None:                         # not specified by user
            if not isinstance(method,list):           # For single core estimations
                if int(joblib.cpu_count()) >= len(p_inv):        # more cores than parameters
                    p_at_once = len(p_inv)
                    warn(f'Evaluating {p_at_once} parameters simultaneously. If RAM is exceeded, use lower p_at_once')
                else:                                            # more parameters than cores
                    p_at_once = joblib.cpu_count()
                    warn(f'Evaluating {p_at_once} parameters simultaneously. If RAM is exceeded, use lower p_at_once')
            else:
                p_at_once = int(joblib.cpu_count()/n_jobs)
                if p_at_once == 0:                          # Catching too many CPUs specified
                    warn(f''
                         f'{n_jobs} Archipelagos are specified, but only {joblib.cpu_count()} CPUs are '
                         f'available.\n Continuing with {joblib.cpu_count()} Islands...')
                    p_at_once = 1
                    n_jobs    = joblib.cpu_count()
                elif p_at_once > len(p_inv):                # Catching not all cores required
                    p_at_once = len(p_inv)


        ## opt_kw
        if opt_kw is None:
            opt_kw = {}

        ## seed
        if 'seed' not in opt_kw.keys():
            opt_kw['seed'] = seed
        elif seed != 10:
            warn(f'seed argument was overwritten by optimizer keyword. Current seed is: {opt_kw["seed"]}')


        ### Workers
        self.worker = Workers(self.model, len(p_inv), module='futures', _msg=False)

        ### Data
        dat2obj = {}
        _dat = self.data
        if isinstance(_dat, dict):
            for k in _dat.keys():
                dat2obj[k] = _dat[k].to_dict()
        else:
            dat2obj = _dat.to_dict()
        ### Error data
        err2obj = {}
        _errs = self.err_dat
        if isinstance(_errs, dict):
            for k in _errs.keys():
                try:
                    err2obj[k] = _errs[k].to_dict()
                except AttributeError:
                    err2obj[k] = _errs[k]
        elif _errs:
            err2obj = _errs.to_dict()
        else:
            err2obj = None

        LC_name = Utils.get_class_name(self.LC)
        ## Get calling program name for LC import
        if LC_name != 'LossCalculator':
            scr_name = Utils.get_calling_script_name(inspect.stack())
        else:
            scr_name = None

        # Prepare Bounds, initial point, and investigated values for each parameter to investigate
        pl_args = []
        i=0
        for invest in p_inv:
            p_opt_i = p_opt[invest]
            bnd_i   = pd.DataFrame(bounds).drop(invest, axis=1).to_dict('list')
            p0_i    = pd.Series(p_opt).drop(invest).to_dict()
            pl_args.append((
                invest,
                bnd_i,
                p0_i,
                list(np.linspace(p_opt_i*(1-dp_rel), p_opt_i*(1+dp_rel), n_points)),
                self.parameter_mapping,
                self.rids,
                self.model._model_type,
                self.model.name,
                dat2obj,
                self.bounds,
                err2obj,
                self.observation_mapping,
                self.worker._folders[i],
                self.model.parameters,
                self.metric,
                self.LC.error_model.error_params,
                parallel,
                evos2reset,
                opt_kw,
                method,
                self.t,
                LC_name,
                scr_name,
                n_jobs,
                tot_evo,
            ))
            i+=1


        # Print Start Message
        Messages.m_pl_start(p_inv, dp_rel, n_points, p_at_once, method, evos2reset, tot_evo)

        res_t = {}
        # Call parallelization framework concurrent.futures
        with futures.ProcessPoolExecutor(max_workers=p_at_once) as ex:
            losses = [ex.submit(_profile_likelihood_calc, arg) for arg in pl_args]

            for loss in futures.as_completed(losses):
                _res = loss.result()
                _inv = list(_res.items())[0][1]["Investigated"]
                res_t[_inv] = _res
                print(f'+++ Finished evaluation of {_inv}')

        if disp:
            self._profile_likelihood_disp(res_t, n=n_points, dp=dp_rel)

        # delete folder structure
        self.worker.shutdown()

        return res_t



    @staticmethod
    def _profile_likelihood_disp(pl_dict, n, dp):
        """
        Displaying Profile Likelihood Hist-Plots for evaluation.

        Arguments:
        ----------
            pl_dict  : dict[dict[dict]]
                Saved profile likelihood information containing evaluated points and losses
            n        : int
                number of points
            dp       : float
                relative range
        """
        # Transform data
        n_pars = len(pl_dict.keys())
        n_rows = int(n_pars / 2) + int(n_pars % 2)
        fig, axs = plt.subplots(n_rows, 2)
        fig.set_size_inches(16, n_pars * 4)

        # Get axes bounds
        x_ticks = [str(p) + " %" for p in np.linspace(-dp*100,dp*100,n)]
        Losses  = []
        for y in pl_dict.keys():
            for y1 in pl_dict[y].keys():
                Losses.append(pl_dict[y][y1]['Loss'])
        y_min   = min(Losses)
        y_max   = max(Losses)


        i, j, k = 0, 0, 0
        for dict1 in pl_dict.items():
            key = dict1[0]
            y = []
            x = []
            for dict2 in dict1[1].items():
                y.append(dict2[1]['Loss'])
                x.append(dict2[1]['value'])


            try:
                axs[i, j].bar(x_ticks, height=y, width=0.65-n*0.05)
                axs[i, j].set_title(key)
                axs[i, j].set_ylabel('Loss')
                axs[i, j].set_xticks(x_ticks)
                axs[i, j].set_ylim(y_min,y_max)
            except:
                axs[j].bar(x_ticks, height=y, width=0.65-n*0.05)
                axs[j].set_title(key)
                axs[j].set_ylabel('Loss')
                axs[j].set_xticks(x_ticks)
                axs[j].set_ylim(y_min, y_max)

            if j == 1:
                i, j = i + 1, 0  # if _h is at position 1, set zero and increase _v
            else:
                j += 1  # else increase _h

        plt.subplots_adjust(
            left=0.1,
            right=0.9,
            bottom=0.1,
            top=0.95,
            wspace=0.4,
            hspace=0.4,
        )

        plt.show()


    def center_bounds(self, p_opt:dict, rel_range:float=0.05):
        """
        Centers the boundaries of the Estimator object around the current optimal point in a given relative range.

        Arguments:
        ----------
            p_opt       : dict
                Current optimal point
            rel_range   : float
                Relative range for altering the bounds


        """

        if rel_range >= 1:
            warn('The relative range rel_range is grater than 1. this will result in negative boundaries!')

        # Initialize new boundaries dict
        bnd_new = {}

        # Add upper and lower bounds
        for par in p_opt.keys():
            bnd_new[par] = [p_opt[par]*(1-rel_range),p_opt[par]*(1+rel_range)]

        # Assign as new boundaries
        self.bounds = bnd_new



def _profile_likelihood_calc(pl_args):

    # Unpack profile likelihood arguments
    inv, bnd, p0, pi_list, parameter_mapping, rids, mod_type, mod_name, data, bounds, errs, obs_map, folder, par_def,\
        metric, err_par, parallel, evos2reset, opt_kw, method, t, LC_name, scr_name, n_jobs, tot_evo = pl_args

    # Change working directory
    os.chdir(folder)

    # Convert Data
    err_dat = None
    if rids is None:
        dat = pd.DataFrame(data)
        if errs is not None:
            err_dat = pd.DataFrame(errs)
    else:
        dat = {}
        err_dat = {}
        for rid in data.keys():
            dat[rid] = pd.DataFrame(data[rid])
            if errs is not None:
                err_dat[rid] = pd.DataFrame(errs[rid])
            else:
                err_dat = None

    # Pseudo Estimator
    self = _rebuild_estimator(mod_name, mod_type, bounds, metric, dat, t, par_def, scr_name=scr_name, err_par=err_par,
                              err_dat=err_dat, obs_map=obs_map, par_map=parameter_mapping, LC_name=LC_name)

    # Init result dict
    res = {}
    info= {}

    # Init save-dict
    losses  = {}
    nr      = 1

    # Iterate over points of inv
    for i in pi_list:
        bnd_temp = {}
        for key in bounds.keys():
            if key != str(inv):
                bnd_temp[key] = bounds[key]

        if parallel:
            self.worker = Workers(self.model, n_jobs, module='futures', _msg=False)
            folders     = self.worker._folders
        else:
            folders     = [folder]

        # Set model parameter to i
        self.model.parameters[inv] = i
        # Prepare tuple for passing
        args2pass = Utils.pack_args(self, folders=folders)

        # Start optimization
        optimization = Optimization()

        # Choose correct objective function
        if self.model._model_type == 'dymola':
            obj = objective
        else:
            obj = objective_fmu

        if not parallel:
            opt_kw = Utils.insert_maxiter(method, tot_evo, opt_kw)  # Insert maximum iterations
            res, info = optimization.optimize(
                    obj,
                    method  = method,
                    bounds  = bnd_temp,
                    p0      = p0,
                    args    = args2pass,
                    mcs     = True,
                    opt_kw  = opt_kw,
            )

        else:
            if isinstance(method,list):
                res, info = self.estimate_parallel(method, n_jobs, optimizer_kwargs=opt_kw, backup=False,
                                                   max_iter=evos2reset, bounds=bnd_temp, _msg=False)
                while info['n_evo'] < tot_evo:
                    res, info = self.estimate_archi_continued(info,evos2reset,backup=False, _msg=False)
            else:
                res, info = self.estimate_parallel(method, 1, optimizer_kwargs=opt_kw, backup=False,
                                                   max_iter=tot_evo, bounds=bnd_temp, _msg=False)

        losses[str(inv)+'_'+str(nr)] = {
            'Investigated' : str(inv),
            'value'        : i,
            'Loss'         : info['fun'],
        }

        nr += 1


    return losses


def _rebuild_estimator(model_name, model_type, bounds, metric, data, t, par_defaults, scr_name, err_par=None, err_dat=None,
                      obs_map=None, par_map=None, LC_name='LossCalculator'):

    # Rebuild Model
    if model_type == 'dymola':
        _model = DymolaModel(model_name)
        _model.initialize(for_est=True)
        _model.parameters = par_defaults
    else:
        _model = FmuModel(model_name)
        _model.initialize(for_est=True)
        _model.parameters = par_defaults


    # Rebuild LossCalculator
    if LC_name != 'LossCalculator':
        main_module = import_module(scr_name)
        Cust_LC_class = getattr(main_module, LC_name)
        if (metric != 'SS') and (err_dat is None):
            _lc = Cust_LC_class(metric=metric, error_model=None, error_model_params=err_par)
        else:
            _lc = Cust_LC_class(metric=metric, err_dat_av=True)
        _lc.observation_mapping = obs_map
    else:
        _lc = None

    # Rebuild estimator
    ## Create
    est = Estimator(_model, bounds, metric, data, t=t, error_model_params=err_par,
                    err_dat=err_dat, observation_mapping=obs_map)

    ## Map parameters
    if isinstance(par_map,list):
        print('List discovered')
        est.parameter_mapping  = par_map
    else:
        est._parameter_mapping = par_map

    ## Include custom LC
    if _lc is not None:
        est.LC = _lc


    return est

def _mc_estimate(fun_args):

    # Unpack Arguments
    mod_name, folders, par_map, t, bounds, rids, data, obs_map, metric, optimizer_kwargs, opt_method, err_par, p0, \
    err_dat, LC_name, scr_name, mod_type, par_def, tot_iter, prog, loss_stop, evos2reset = fun_args

    # Pseudo Estimator
    self = _rebuild_estimator(mod_name, mod_type, bounds, metric, data, t, par_def, scr_name=scr_name, err_par=err_par,
                              err_dat=err_dat, obs_map=obs_map, par_map=par_map, LC_name=LC_name)

    ## Estimate
    if not isinstance(opt_method,list):
        if not opt_method.startswith('pg'):
            res, info = self.estimate(opt_method, p0=p0, max_iter=tot_iter, optimizer_kwargs=optimizer_kwargs)
        else:
            res, info = self.estimate_parallel(opt_method,n_workers=1, max_iter=tot_iter,
                                               optimizer_kwargs=optimizer_kwargs, _msg=False)
    else:
        # Switch to first folder branch
        os.chdir(folders)

        # Temp_kwargs definition
        temp_kw = {}
        for k in optimizer_kwargs.keys():
            if k != 'n_evo':
                temp_kw[k] = optimizer_kwargs[k]

        # Create nested folder branch
        res, info = self.estimate_parallel(opt_method,n_workers=1,optimizer_kwargs=optimizer_kwargs,
                                           max_iter=0, _msg=False)

        # Init counters
        y_old      = 0
        _no_impr   = 0

        # >>> Same as estimate archi continued
        while (info['n_evo'] < tot_iter) \
            and (info['fun'] > loss_stop) and (_no_impr <= prog):

            res, info = self.estimate_archi_continued(info, n_evo=evos2reset, _msg=False)

            if info['fun'] == y_old:
                _no_impr +=1
            else:
                _no_impr = 0
                y_old = info['fun']

    return res
