#!/usr/bin/env python
# -*-coding:utf8 -*

"""
Module for the definition of FunctionalData types.

This modules is used to defined different types of functional data. The
different types are: Univariate Functional Data, Irregular Functional data and
Multivariate Functional Data.
"""
import numpy as np
import pygam

from abc import ABC, abstractmethod
from collections import UserList

from sklearn.metrics import pairwise_distances

from ..preprocessing.smoothing.bandwidth import Bandwidth
from ..preprocessing.smoothing.local_polynomial import LocalPolynomial
from ..preprocessing.smoothing.smoothing_splines import SmoothingSpline
from ..misc.utils import get_dict_dimension_, get_obs_shape_
from ..misc.utils import integration_weights_, outer_
from ..misc.utils import range_standardization_

###############################################################################
# Checkers for parameters

def _check_dict_array(argv_dict, argv_array):
    """Raise an error in case of dimension conflicts between the arguments.

    An error is raised when `argv_dict` (a dictionary) and `argv_array`
    (a np.ndarray) do not have coherent common dimensions. The first dimension
    of `arg_array` is assumed to represented the number of observation.
    """
    dim_dict = get_dict_dimension_(argv_dict)
    dim_array = argv_array.shape[1:]
    if dim_dict != dim_array:
        raise ValueError(f"{argv_dict} and {argv_array} do not have coherent"
                         " dimension.")

def _check_dict_dict(argv1, argv2):
    """Raise an error in case of dimension conflicts between the arguments.

    An error is raised when `argv1` (a nested dictonary) and `argv2` (a
    dictionary) do not have coherent common dimensions.
    """
    has_obs_shape = [obs.shape == get_obs_shape_(argv1, idx)
                     for idx, obs in argv2.items()]
    if not np.all(has_obs_shape):
        raise ValueError(f"{argv1} and {argv2} do not"
                         " have coherent dimension.")

def _check_type(argv, category):
    """Raise an error if `argv` is not of type category."""
    if not isinstance(argv, category):
        raise TypeError(f"Argument must be FunctionalData, not"
                        f" {type(argv).__name__}")

def _check_dict_type(argv, category):
    """Raise an error if all elements of `argv` are not of type `category`."""
    is_cat = [isinstance(obj, category) for obj in argv.values()]
    if not np.all(is_cat):
        raise TypeError(f"Argument values must be {category.__name__}")

def _check_dict_len(argv):
    """Raise an error if all elements of `argv` do not have equal length."""
    lengths = [len(obj) for obj in argv.values()]
    if len(set(lengths)) > 1:
        raise ValueError("The number of observations is different across the"
                         " dimensions.""")

def _check_same_type(argv1, argv2):
    """Raise an error if `argv1` and `argv2` have different type."""
    if not isinstance(argv2, type(argv1)):
        raise TypeError(f"{argv1} and {argv2} do not have the same type.")

def _check_same_nobs(*argv):
    """Raise an arror if elements in argv have different number of obs."""
    n_obs = set(obj.n_obs for obj in argv)
    if len(n_obs) > 1:
        raise ValueError("Elements do not have the same number"
                         " of observations.")

def _check_same_ndim(argv1, argv2):
    """Raise an error if `argv1` and `argv2` have different number of dim."""
    if argv1.n_dim != argv2.n_dim:
        raise ValueError(f"{argv1} and {argv2} do not have the same number"
                         " of dimensions.")

def _check_argvals_equality_dense(argv1, argv2):
    """Raise an error if `argv1` and `argv2` are not equal."""
    argvs_equal = all(np.array_equal(argv1[key], argv2[key]) for key in argv1)
    if not argvs_equal:
        raise ValueError(f"{argv1} and {argv2} do not have the same sampling"
                         " points.")

def _check_argvals_equality_irregular(argv1, argv2):
    """Raise an error if `argv1` and `argv2` are not equal."""
    temp = []
    for points1, points2 in zip(argv1.values(), argv2.values()):
        temp.append(all(np.array_equal(points1[key], points2[key])
                        for key in points1))

    argvs_equal = all(temp)
    if not argvs_equal:
        raise ValueError(f"{argv1} and {argv2} do not have the same sampling"
                         " points.")

###############################################################################
# Class FunctionalData

class FunctionalData(ABC):
    """Metaclass for the definition of diverse functional data objects.

    Parameters
    ----------
    argvals: list
    values: list
    category: str, {'univariate', 'irregular', 'multivariate'}

    """

    @staticmethod
    @abstractmethod
    def _check_argvals(argvals):
        _check_type(argvals, dict)

    @staticmethod
    @abstractmethod
    def _check_values(values):
        pass

    @staticmethod
    @abstractmethod
    def _check_argvals_values(argvals, values):
        pass

    @staticmethod
    @abstractmethod
    def _perform_computation(fdata1, fdata2, func):
        pass

    def __init__(self, argvals, values, category):
        """Initialize FunctionalData object."""
        super().__init__()
        self.argvals = argvals
        self.values = values
        self.category = category

    def __repr__(self):
        """Override print function."""
        return (f"{self.category.capitalize()} functional data object with"
                f" {self.n_obs} observations on a {self.n_dim}-dimensional"
                " support.")

    @abstractmethod
    def __getitem__(self, index):
        """Override getitem function, called when self[index]."""
        pass

    def __add__(self, obj):
        """Override add function."""
        return self._perform_computation(self, obj, np.add)

    def __sub__(self, obj):
        """Override sub function."""
        return self._perform_computation(self, obj, np.subtract)

    def __mul__(self, obj):
        """Overrude mul function."""
        return self._perform_computation(self, obj, np.multiply)

    def __rmul__(self, obj):
        """Override rmul function."""
        return self * obj

    def __truediv__(self, obj):
        """Override truediv function."""
        return self._perform_computation(self, obj, np.divide)

    def __floordiv__(self, obj):
        """Override floordiv function."""
        return self / obj

    @property
    def argvals(self):
        """Getter for argvals."""
        return self._argvals

    @argvals.setter
    def argvals(self, new_argvals):
        self._check_argvals(new_argvals)
        if hasattr(self, 'values'):
            self._check_argvals_values(new_argvals, self.values)
        self._argvals = new_argvals

    @property
    def argvals_stand(self):
        """Getter for argvals_stand."""
        return self._argvals_stand

    @argvals_stand.setter
    def argvals_stand(self, new_argvals_stand):
        self._argvals_stand = new_argvals_stand

    @property
    def values(self):
        """Getter for values."""
        return self._values

    @values.setter
    def values(self, new_values):
        self._check_values(new_values)
        if hasattr(self, 'argvals'):
            self._check_argvals_values(self.argvals, new_values)
        self._values = new_values

    @property
    def category(self):
        """Getter for category."""
        return self._category

    @category.setter
    def category(self, new_category):
        self._category = new_category

    @property
    def n_obs(self):
        """Get the number of observations of the functional data.

        Returns
        -------
        n_obs: int
            Number of observations within the functional data.

        """
        return len(self.values)

    @property
    @abstractmethod
    def range_obs(self):
        """Get the range of the observations of the object."""
        pass

    @property
    def n_dim(self):
        """Get the number of input dimension of the functional data.

        Returns
        -------
        n_dim: int
            Number of input dimension with the functional data.

        """
        return len(self.argvals)

    @property
    @abstractmethod
    def n_points(self):
        """Get the mean number of sampling points."""
        pass

    @property
    @abstractmethod
    def range_dim(self):
        """Range of the `argvals` for each of the dimension."""
        pass

    @property
    @abstractmethod
    def shape(self):
        """Shape of the data for each dimension."""
        pass

    @abstractmethod
    def is_compatible(self, fdata):
        """Check if `fdata` is compatible with `self`."""
        _check_same_type(self, fdata)
        _check_same_nobs(self, fdata)
        _check_same_ndim(self, fdata)

    @abstractmethod
    def mean(self, smooth=None, **kwargs):
        """Compute an estimate of the mean."""
        pass

    @abstractmethod
    def covariance(self, mean=None, smooth=None, **kwargs):
        """Compute an estimate of the covariance."""
        pass

    @abstractmethod
    def smooth(self, points, neighborhood, points_estim=None, degree=0,
               kernel="epanechnikov", bandwidth=None):
        """Smooth the data."""
        pass

###############################################################################
# Class DenseFunctionalData

class DenseFunctionalData(FunctionalData):
    r"""A class for defining Dense Functional Data.

    A class used to define dense functional data. We denote by :math:`n`, the
    number of observations and by :math:`p`, the number of input dimensions.
    Here, we are in the case of univariate functional data, and so the output
    dimension will be :math:`\mathbb{R}`.

    Parameters
    ----------
    argvals: dict
        The sampling points of the functional data. Each entry of the
        dictionary represents an input dimension. The shape of the :math:`j`th
        dimension is :math:`(m_j,)` for :math:`0 \leq j \leq p`.
    values: np.ndarray
        The values of the functional data. The shape of the array is
        :math:`(n, m_1, \dots, m_p)`.

    Examples
    --------
    >>> argvals = {'input_dim_0': np.array([1, 2, 3, 4]),
                   'input_dim_1': np.array([5, 6, 7])}

    >>> values = np.array([[[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]],
                   [[5, 6, 7], [5, 6, 7], [5, 6, 7], [5, 6, 7]],
                   [[3, 4, 5], [3, 4, 5], [3, 4, 5], [3, 4, 5]],
                   [[3, 4, 5], [3, 4, 5], [3, 4, 5], [3, 4, 5]],
                   [[3, 4, 5], [3, 4, 5], [3, 4, 5], [3, 4, 5]]])

    >>> DenseFunctionalData(argvals, values)

    """

    @staticmethod
    def _check_argvals(argvals):
        """Check the user provided `argvals`."""
        FunctionalData._check_argvals(argvals)
        _check_dict_type(argvals, np.ndarray)

    @staticmethod
    def _check_values(values):
        """Check the use provided `values`."""
        _check_type(values, np.ndarray)

    @staticmethod
    def _check_argvals_values(argvals, values):
        """Check the compatibility of argvals and values."""
        _check_dict_array(argvals, values)

    @staticmethod
    def _perform_computation(fdata1, fdata2, func):
        """Perform computation defined by `func`."""
        if fdata1.is_compatible(fdata2):
            new_values = func(fdata1.values, fdata2.values)
        return DenseFunctionalData(fdata1.argvals, new_values)

    def __init__(self, argvals, values):
        """Initialize UnivariateFunctionalData object."""
        super().__init__(argvals, values, 'univariate')

    def __getitem__(self, index):
        """Overrride getitem function, called when self[index].

        Parameters
        ----------
        index: int
            The observation(s) of the object to retrive.

        Returns
        -------
        data: DenseFunctionalData object
            The selected observation(s) as DenseFunctionalData object.

        """
        argvals = self.argvals
        values = self.values[index]

        if len(argvals) == len(values.shape):
            values = values[np.newaxis]
        return DenseFunctionalData(argvals, values)

    @property
    def argvals(self):
        """Getter for argvals."""
        return super().argvals

    @argvals.setter
    def argvals(self, new_argvals):
        super(DenseFunctionalData, self.__class__).\
            argvals.fset(self, new_argvals)

        argvals_stand = {}
        for dim, points in new_argvals.items():
            argvals_stand[dim] = range_standardization_(points)
        self.argvals_stand = argvals_stand

    @property
    def range_obs(self):
        """Get the range of the observations of the object.

        Returns
        -------
        min, max: tuple
            Tuple containing the mimimum and maximum values taken by all the
            observations for the object.

        """
        return np.min(self.values), np.max(self.values)

    @property
    def n_points(self):
        """Get the mean number of sampling points.

        Returns
        -------
        n_points: dict
            A dictionary with the same shape than argavls with the number of
            sampling points along each axis.

        """
        return {i: len(points) for i, points in self.argvals.items()}

    @property
    def range_dim(self):
        """Get the range of the `argvals` for each of the dimension.

        Returns
        -------
        ranges: dict
            Dictionary containing the range of the argvals for each of the
            input dimension.

        """
        return {idx: (min(argval), max(argval))
                for idx, argval in self.argvals.items()}

    @property
    def shape(self):
        r"""Get the shape of the data for each dimension.

        Returns
        -------
        shape: dict
            Dictionary containing the number of points for each of the
            dimension. It corresponds to :math:`m_j` for
            :math:`0 \leq j \leq p`.

        """
        return {idx: len(dim) for idx, dim in self.argvals.items()}

    def as_irregular(self):
        """Convert `self` from Dense to Irregular functional data.

        Coerce a DenseFunctionalData object into an IrregularFunctionalData
        object.

        Returns
        -------
        obj: IrregularFunctionalData
            An object of the class IrregularFunctionalData

        """
        new_argvals = dict.fromkeys(self.argvals.keys(), {})
        for dim in new_argvals.keys():
            temp = {}
            for idx in range(self.n_obs):
                temp[idx] = self.argvals[dim]
            new_argvals[dim] = temp

        new_values = {}
        for idx in range(self.n_obs):
            new_values[idx] = self.values[idx]

        return IrregularFunctionalData(new_argvals, new_values)

    def is_compatible(self, fdata):
        """Check if `fdata` is compatible with `self`.

        Two DenseFunctionalData object are said to be compatible if they
        have the same number of observations and dimensions. Moreover, they
        must have (strictly) the same sampling points.

        Parameters
        ----------
        fdata: DenseFunctionalData object
            The object to compare with `self`.

        Returns
        -------
        True
            If the objects are compatible.

        """
        super().is_compatible(fdata)
        _check_argvals_equality_dense(self.argvals, fdata.argvals)
        return True

    def mean(self, smooth=None, **kwargs):
        """Compute an estimate of the mean.

        Parameters
        ----------
        smooth: str, default=None
            Name of the smoothing method to use. Currently, not implemented.

        Keyword Args
        ------------
        kernel_name: str, default='epanechnikov'
            Name of the kernel used for local polynomial smoothing.
        degree: int, default=1
            Degree used for local polynomial smoothing.
        bandwidth: float, default=1
            Bandwidth used for local polynomial smoothing.
        n_basis: int, default=10
            Number of splines basis used for GAM smoothing.

        Returns
        -------
        obj: DenseFunctionalData object
            An estimate of the mean as a DenseFunctionalData object with the
            same argvals as `self` and one observation.

        """
        mean_estim = self.values.mean(axis=0)

        if smooth is not None:
            argvals = self.argvals['input_dim_0']
            if self.n_dim > 1:
                raise ValueError('Only one dimensional data can be smoothed.')
            if smooth == 'LocalLinear':
                p = self.n_points['input_dim_0']
                points = kwargs.get('points', 0.5)
                neigh = kwargs.get('neighborhood',
                                   np.int(p * np.exp(-(np.log(np.log(p)))**2)))
                data_smooth = self.smooth(points=points,
                                          neighborhood=neigh)
                mean_estim = data_smooth.values.mean(axis=0)
            elif smooth == 'GAM':
                n_basis = kwargs.get('n_basis', 10)
                argvals = self.argvals['input_dim_0']
                mean_estim = pygam.LinearGAM(pygam.s(0, n_splines=n_basis)).\
                    fit(argvals, mean_estim).\
                    predict(argvals)
            elif smooth == 'SmoothingSpline':
                ss = SmoothingSpline()
                mean_estim = ss.fit_predict(argvals, mean_estim)
            else:
                raise NotImplementedError('Smoothing method not implemented.')
        return DenseFunctionalData(self.argvals, mean_estim[np.newaxis])

    def covariance(self, mean=None, smooth=None, **kwargs):
        """Compute an estimate of the covariance.

        Parameters
        ----------
        smooth: str, default=None
            Name of the smoothing method to use. Currently, not implemented.
        mean: DenseFunctionalData, default=None
            An estimate of the mean of self. If None, an estimate is computed.

        Returns
        -------
        obj: DenseFunctionalData object
            An estimate of the covariance as a two-dimensional
            DenseFunctionalData object with same argvals as `self`.

        Keyword Args
        ------------
        kernel_name: str, default='epanechnikov'
            Name of the kernel used for local polynomial smoothing.
        degree: int, default=1
            Degree used for local polynomial smoothing.
        bandwidth: float, default=1
            Bandwidth used for local polynomial smoothing.
        n_basis: int, default=10
            Number of splines basis used for GAM smoothing.

        References
        ----------
        * Yao, Müller and Wang (2005), Functional Data Analysis for Sparse
        Longitudinal Data,
        Journal of the American Statistical Association, Vol. 100, No. 470
        * Staniswalis, J. G., and Lee, J. J. (1998), “Nonparametric Regression
        Analysis of Longitudinal Data,” Journal of the American Statistical
        Association, 93, 1403–1418.

        """
        if self.n_dim > 1:
            raise ValueError('Only one dimensional functional data are'
                             ' supported')

        p = self.n_points['input_dim_0']
        argvals = self.argvals['input_dim_0']
        if mean is None:
            mean = self.mean(smooth)
        data = self.values - mean.values
        cov = np.dot(data.T, data) / (self.n_obs - 1)
        cov_diag = np.copy(np.diag(cov))

        if smooth is not None:
            # Remove covariance diagonale because of measurement errors.
            np.fill_diagonal(cov, None)
            cov = cov[~np.isnan(cov)]

            # Define train vector
            train_ = np.vstack((
                np.repeat(argvals, repeats=len(argvals)),
                np.tile(argvals, reps=len(argvals)))
            )

            train = train_[:, train_[0, :] != train_[1, :]]

            if smooth == 'LocalLinear':
                points = kwargs.get('points', 0.5)
                neigh = kwargs.get('neighborhood',
                                   np.int(p * np.exp(-(np.log(np.log(p)))**2)))
                data_smooth = self.smooth(points=points,
                                          neighborhood=neigh)
                data = data_smooth.values - mean.values
                cov = np.dot(data.T, data) / (self.n_obs - 1)
            elif smooth == 'GAM':
                n_basis = kwargs.get('n_basis', 10)

                cov = pygam.LinearGAM(pygam.te(0, 1, n_splines=n_basis)).\
                    fit(np.transpose(train), cov).\
                    predict(np.transpose(train_)).\
                    reshape((len(argvals), len(argvals)))
            else:
                raise NotImplementedError('Smoothing method not implemented.')

        # Ensure the covariance is symmetric.
        cov = (cov + cov.T) / 2

        # Smoothing the diagonal of the covariance (Yao, Müller and Wang, 2005)
        lp = LocalPolynomial(kernel_name=kwargs.get('kernel_name', 'gaussian'),
                             bandwidth=kwargs.get('bandwidth', 1),
                             degree=kwargs.get('degree', 1))
        var_hat = lp.fit_predict(argvals, cov_diag, argvals)
        # Estimate noise variance (Staniswalis and Lee, 1998)
        ll = argvals[len(argvals) - 1] - argvals[0]
        lower = np.sum(~(argvals >= (argvals[0] + 0.25 * ll)))
        upper = np.sum((argvals <= (argvals[len(argvals) - 1] - 0.25 * ll)))
        weights = integration_weights_(argvals[lower:upper], method='trapz')
        nume = np.dot(weights, (var_hat - cov_diag)[lower:upper])
        self.var_noise = np.maximum(nume / argvals[upper] - argvals[lower], 0)

        new_argvals = {'input_dim_0': argvals, 'input_dim_1': argvals}
        return DenseFunctionalData(new_argvals, cov[np.newaxis])

    def smooth(self, points, neighborhood, points_estim=None, degree=0,
               kernel="epanechnikov", bandwidth=None):
        """Smooth the data.

        Notes
        -----
        Only, one dimensional IrregularFunctionalData can be smoothed.

        Parameters
        ----------
        points: np.array
            Points at which the Bandwidth is estimated.
        neighborhood: np.array
            Neighborhood considered for each each points. Should have the same
            shape than points.
        points_estim: np.array, default=None
            Points at which the curves are estimated. The default is None,
            meaning we use the argvals as estimation points.
        degree: int, default=2
            Degree for the local polynomial smoothing.
        kernel: str, default='epanechnikov'
            The name of the kernel to use.
        bandwidth: Bandwidth, default=None
            An instance of Bandwidth for the smoothing.

        Returns
        -------
        obj: IrregularFunctionalData
            A smoothed version of the data.

        """
        if self.n_dim != 1:
            raise NotImplementedError('Only one dimensional data can be'
                                      ' smoothed.')

        data = self.as_irregular()
        data_smooth = data.smooth(points, neighborhood,
                                  points_estim=points_estim,
                                  degree=degree,
                                  kernel=kernel,
                                  bandwidth=bandwidth)
        return data_smooth.as_dense()

    def pairwise_distance(self, metric='euclidean'):
        """Compute the pairwise distance between the data.

        Parameters
        ----------
        metric: str, default='euclidean'
            The metric to use when calculating distance between instances in a
            functional data object.

        Returns
        -------
        D: np.ndarray, shape=(n_obs, n_obs)
            A distance matrix D such that D_{i, j} is the distance between the
            ith and jth observations of the functional data object,

        """
        if self.n_dim > 1:
            raise NotImplementedError('The distance computation is not'
                                      ' implemented for data with dimension'
                                      ' greater than 1.')
        return pairwise_distances(self.values, metric=metric)

    def concatenate(self, data):
        """Concatenate two DenseFunctionalData.

        Parameters
        ----------
        data: DenseFunctionalData
            The data to concatenate with self.

        Returns
        -------
        res: DenseFunctionalData
            The concatenation of self and data.

        """
        return concatenate_(self, data)

###############################################################################
# Class IrregularFunctionalData

class IrregularFunctionalData(FunctionalData):
    r"""A class for defining Irregular Functional Data.

    Parameters
    ----------
    argvals: dict
        The sampling points of the functional data. Each entry of the
        dictionary represents an input dimension. Then, each dimension is a
        dictionary where entries are the different observations. So, the
        observation :math:`i` for the dimension :math:`j` is a `np.ndarray`
        with shape :math:`(m^i_j,)` for :math:`0 \leq i \leq n` and
        :math:`0 \leq j \leq p`.
    values: dict
        The values of the functional data. Each entry of the dictionary is an
        observation of the process. And, an observation is represented by a
        `np.ndarray` of shape :math:`(n, m_1, \dots, m_p)`. It should not
        contain any missing values.

    Examples
    --------
    >>> argvals = {'input_dim_0': {
                        0: np.array([1, 2, 3, 4]),
                        1: np.array([2, 4])},
                   'input_dim_1': {
                        0: np.array([5, 6, 7]),
                        1: np.array([1, 2, 3])}
                  }

    >>> values = {0: np.array([[1, 2, 3], [4, 1, 2], [3, 4, 1], [2, 3, 4]]),
                  1: np.array([[1, 2, 3], [1, 2, 3]])}

    >>> IrregularFunctionalData(argvals, values)

    """

    @staticmethod
    def _check_argvals(argvals):
        """Check the user provided `argvals`."""
        FunctionalData._check_argvals(argvals)
        for obj in argvals.values():
            _check_type(obj, dict)
            _check_dict_type(obj, np.ndarray)
        _check_dict_len(argvals)

    @staticmethod
    def _check_values(values):
        """Check the user provided `values`."""
        _check_type(values, dict)
        for obj in values.values():
            _check_type(obj, np.ndarray)

    @staticmethod
    def _check_argvals_values(argvals, values):
        """Check the compatibility of argvals and values."""
        _check_dict_dict(argvals, values)

    @staticmethod
    def _perform_computation(fdata1, fdata2, func):
        """Perform computation defined by `func`."""
        if fdata1.is_compatible(fdata2):
            new_values = {}
            for (idx, obs1), (_, obs2) in zip(fdata1.values.items(),
                                              fdata2.values.items()):
                new_values[idx] = func(obs1, obs2)
        return IrregularFunctionalData(fdata1.argvals, new_values)

    def __init__(self, argvals, values):
        """Initialize IrregularFunctionalData object."""
        super().__init__(argvals, values, 'irregular')

    def __getitem__(self, index):
        """Overrride getitem function, called when self[index].

        Parameters
        ----------
        index: int
            The observation(s) of the object to retrive.

        Returns
        -------
        data: IrregularFunctionalData object
            The selected observation(s) as IrregularFunctionalData object.

        """
        if isinstance(index, slice):
            indices = index.indices(self.n_obs)

            argvals = {}
            for idx, dim in self.argvals.items():
                argvals[idx] = {i: dim.get(i) for i in range(*indices)}
            values = {i: self.values.get(i) for i in range(*indices)}
        else:
            argvals = {idx: {index: points.get(index)}
                       for idx, points in self.argvals.items()}
            values = {index: self.values.get(index)}
        return IrregularFunctionalData(argvals, values)

    @property
    def argvals(self):
        """Getter for argvals."""
        return super().argvals

    @argvals.setter
    def argvals(self, new_argvals):
        super(IrregularFunctionalData, self.__class__).\
            argvals.fset(self, new_argvals)

        points = self.gather_points()
        argvals_stand = {}
        for dim, obss in new_argvals.items():
            max_x, min_x = np.max(points[dim]), np.min(points[dim])

            argvals_stand[dim] = {}
            for obs, point in obss.items():
                argvals_stand[dim][obs] = range_standardization_(point,
                                                                 max_x, min_x)
        self.argvals_stand = argvals_stand

    @property
    def range_obs(self):
        """Get the range of the observations of the object.

        Returns
        -------
        min, max: tuple
            Tuple containing the mimimum and maximum values taken by all the
            observations for the object.

        """
        ranges = [(np.min(obs), np.max(obs)) for obs in self.values.values()]
        return min(min(ranges)), max(max(ranges))

    @property
    def n_points(self):
        """Get the mean number of sampling points.

        Returns
        -------
        n_points: dict
            A dictionary with the same shape than argavls with the number of
            sampling points along each axis.

        """
        n_points = {}
        for i, points in self.argvals.items():
            n_points[i] = np.mean([len(p) for p in points.values()])
        return n_points

    @property
    def range_dim(self):
        """Get the range of the `argvals` for each of the dimension.

        Returns
        -------
        ranges: dict
            Dictionary containing the range of the argvals for each of the
            input dimension.

        """
        ranges = {idx: list(argval.values())
                  for idx, argval in self.argvals.items()}
        return {idx: (min(map(min, dim)), max(map(max, dim)))
                for idx, dim in ranges.items()}

    @property
    def shape(self):
        r"""Get the shape of the data for each dimension.

        Returns
        -------
        shape: dict
            Dictionary containing the number of points for each of the
            dimension. It corresponds to :math:`m_j` for
            :math:`0 \leq j \leq p`.

        """
        return {idx: len(dim) for idx, dim in self.gather_points().items()}

    def gather_points(self):
        """Gather all the `argvals` for each of the dimensions separetely.

        Returns
        -------
        argvals: dict
            Dictionary containing all the unique observations points for each
            of the input dimension.

        """
        return {idx: np.unique(np.hstack(list(dim.values())))
                for idx, dim in self.argvals.items()}

    def as_dense(self):
        """Convert `self` from Irregular to Dense functional data.

        Coerce an IrregularFunctionalData object into a DenseFunctionalData
        object.

        Note
        ----
        We coerce an IrregularFunctionalData object into a DenseFunctionalData
        object by gathering all the sampling points from the different
        dimension into one, and set the value to `np.nan` for the not observed
        points.

        Returns
        -------
        obj: DenseFunctionalData
            An object of the class DenseFunctionalData

        """
        new_argvals = self.gather_points()
        new_values = np.full((self.n_obs,) + tuple(self.shape.values()),
                             np.nan)

        # Create the index definition domain for each of the observation
        index_obs = {}
        for obs in self.values.keys():
            index_obs_dim = []
            for dim in new_argvals.keys():
                _, idx, _ = np.intersect1d(new_argvals[dim],
                                           self.argvals[dim][obs],
                                           return_indices=True)
                index_obs_dim.append(idx)
            index_obs[obs] = index_obs_dim

        # Create mask arrays
        mask_obs = {obs: np.full(tuple(self.shape.values()), False)
                    for obs in self.values.keys()}
        for obs in self.values.keys():
            mask_obs[obs][tuple(np.meshgrid(*index_obs[obs]))] = True

        # Assign values
        for obs in self.values.keys():
            new_values[obs][mask_obs[obs]] = self.values[obs].flatten()

        return DenseFunctionalData(new_argvals, new_values)

    def is_compatible(self, fdata):
        """Check if `fdata` is compatible with `self`.

        Two IrregularFunctionalData object are said to be compatible if they
        have the same number of observations and dimensions. Moreover, they
        must have (strictly) the same sampling points.

        Parameters
        ----------
        fdata : IrregularFunctionalData object
            The object to compare with `self`.

        Returns
        -------
        True
            If the objects are compatible.

        """
        super().is_compatible(fdata)
        _check_argvals_equality_irregular(self.argvals, fdata.argvals)
        return True

    def mean(self, smooth=None, **kwargs):
        """Compute an estimate of the mean.

        Parameters
        ----------
        smooth: str, default=None
            Name of the smoothing method. Currently, not implemented.

        Returns
        -------
        obj: DenseFunctionalData object
            An estimate of the mean as a DenseFunctionalData object with a
            concatenation of the self.argvals as argvals and one observation.

        """
        dense_self = self.as_dense()
        mean_estim = np.nanmean(dense_self.values, axis=0, keepdims=True)
        return DenseFunctionalData(dense_self.argvals, mean_estim)

    def covariance(self, mean=None, smooth=None, **kwargs):
        """Compute an estimate of the covariance."""
        pass

    def smooth(self, points, neighborhood, points_estim=None, degree=0,
               kernel="epanechnikov", bandwidth=None):
        """Smooth the data.

        Notes
        -----
        Only, one dimensional IrregularFunctionalData can be smoothed.

        Parameters
        ----------
        points: np.array
            Points at which the Bandwidth is estimated.
        neighborhood: np.array
            Neighborhood considered for each each points. Should have the same
            shape than points.
        points_estim: np.array, default=None
            Points at which the curves are estimated. The default is None,
            meaning we use the argvals as estimation points.
        degree: int, default=0
            Degree for the local polynomial smoothing.
        kernel: str, default='epanechnikov'
            The name of the kernel to use.
        bandwidth: Bandwidth, default=None
            An instance of Bandwidth for the smoothing.

        Returns
        -------
        obj: IrregularFunctionalData
            A smoothed version of the data.

        """
        if self.n_dim > 1:
            raise NotImplementedError('Currently, only one dimensional data'
                                      ' can be smoothed.')

        if bandwidth is None:
            band_obj = Bandwidth(points=points, neighborhood=neighborhood)
            bandwidth = band_obj(self).bandwidths

        argvals = self.argvals['input_dim_0'].values()
        values = self.values.values()
        smooth_argvals, smooth_values = {}, {}
        for i, (arg, val, b) in enumerate(zip(argvals, values, bandwidth)):
            if points_estim is None:
                points_estim = arg

            lp = LocalPolynomial(kernel_name=kernel,
                                 bandwidth=b,
                                 degree=degree)
            pred = lp.fit_predict(arg, val, points_estim)
            smooth_argvals[i] = points_estim
            smooth_values[i] = pred
        return IrregularFunctionalData({'input_dim_0': smooth_argvals},
                                       smooth_values)

###############################################################################
# Class MultivariateFunctionalData

class MultivariateFunctionalData(UserList):
    r"""A class for defining Multivariate Functional Data.

    An instance of MultivariateFunctionalData is a list containing objects of
    the class DenseFunctionalData or IrregularFunctionalData.

    Notes
    -----
    Be careful that we will not check if all the elements have the same type.
    It is possible to create MultivariateFunctionalData containing both
    Dense and Iregular functional data. However, only this two types are
    allowed to be in the list.

    Parameters
    ----------
    data: list
        The list containing the elements of the MultivariateFunctionalData.

    """

    @staticmethod
    def _check_data(new_data):
        """Check the user provided `data`."""
        for obj in new_data:
            _check_type(obj, (DenseFunctionalData, IrregularFunctionalData))
        _check_same_nobs(*new_data)

    def __init__(self, initlist=None):
        """Initialize MultivariateFunctionalData object."""
        self.data = initlist

    def __repr__(self):
        """Override print function."""
        return (f"Multivariate functional data object with {self.n_functional}"
                f" functions of {self.n_obs} observations.")

    @property
    def data(self):
        """Getter for data."""
        return self._data

    @data.setter
    def data(self, new_data):
        if new_data is not None:
            self._check_data(new_data)
            self._data = new_data
        else:
            self._data = []

    @property
    def n_obs(self):
        """Get the number of observations of the functional data.

        Returns
        -------
        n_obs: int
            Number of observations within the functional data.

        """
        return self.data[0].n_obs if len(self) > 0 else 0

    @property
    def n_functional(self):
        """Get the number of functional data with `self`.

        Returns
        -------
        n_functional: int
            Number of functions in the list.

        """
        return len(self)

    @property
    def n_dim(self):
        """Get the dimension of the functional data.

        Returns
        -------
        dim: list
            List containing the dimension of each component in the functional
            data.

        """
        return [i.n_dim for i in self]

    @property
    def range_obs(self):
        """Get the range of the observations of the object.

        Returns
        -------
        (min, max): list of tuples
            List of tuples containing the mimimum and maximum values taken by
            all the observations for the object for each function.

        """
        return [i.range_obs for i in self]

    @property
    def n_points(self):
        """Get the mean number of sampling points.

        Returns
        -------
        n_points: list of dict
            A list of dictionary with the same shape than argvals with the
            number of sampling points along each axis for each function.

        """
        return [i.n_points for i in self]

    @property
    def range_points(self):
        """Get the range of the `argvals` for each of the dimension.

        Returns
        -------
        ranges: list of dict
            List of dictionary containing the range of the argvals for each of
            the input dimension for each function.

        """
        return [i.range_dim for i in self]

    @property
    def shape(self):
        r"""Get the shape of the data for each dimension.

        Returns
        -------
        shape: list of dict
            List of dictionary containing the number of points for each of the
            dimension for each function. It corresponds to :math:`m_j` for
            :math:`0 \leq j \leq p`.

        """
        return [i.shape for i in self]

    def append(self, item):
        """Add an item to `self`.

        Parameters
        ----------
        item: DenseFunctionalData or IrregularFunctionalData
            Item to add.

        """
        if len(self.data) == 0:
            self.data = [item]
        else:
            _check_same_nobs(self, item)
            self.data.append(item)

    def extend(self, other):
        """Extend the list of FunctionalData by appending from iterable."""
        super().extend(other)

    def insert(self, i, item):
        """Insert an item `item` at a given position `i`."""
        _check_same_nobs(self, item)
        self.data.insert(i, item)

    def remove(self, item):
        """Remove the first item from `self` where value is `item`."""
        raise NotImplementedError

    def pop(self, i=-1):
        """Remove the item at the given position in the list, and return it."""
        return super().pop(i)

    def clear(self):
        """Remove all items from the list."""
        super().clear()

    def index(self, item, *args):
        """Return first item of the list equald to x."""
        raise NotImplementedError

    def count(self, item):
        """Return the number of times `item` appears in the list."""
        raise NotImplementedError

    def sort(self, *args, **kwds):
        """Sort the items of the list in place."""
        raise NotImplementedError

    def reverse(self):
        """Reserve the elements of the list in place."""
        super().reverse()

    def copy(self):
        """Return a shallow copy of the list."""
        return super().copy()

    def get_obs(self):
        """Return a generator over the observation."""
        for idx in range(self.n_obs):
            yield MultivariateFunctionalData([obs[idx] for obs in self])

    def mean(self, smooth=None, **kwargs):
        """Compute an estimate of the mean.

        Parameters
        ----------
        smooth: str, default=None
            Name of the smoothing method. Currently, not implemented.

        Returns
        -------
        obj: MultivariateFunctionalData object
            An estimate of the mean as a MultivariateFunctionalData object
            with a concatenation of the self.argvals as argvals and one
            observation.

        """
        return MultivariateFunctionalData([i.mean(smooth, **kwargs)
                                           for i in self])

    def covariance(self, mean=None, smooth=None, **kwargs):
        """Compute an estimate of the covariance.

        Parameters
        ----------
        smooth: str, default=None
            Name of the smoothing method to use. Currently, not implemented.
        mean: MultivariateFunctionalData, default=None
            An estimate of the mean of self. If None, an estimate is computed.

        Returns
        -------
        obj: MultivariateFunctionalData object
            An estimate of the covariance as a two-dimensional
            MultivariateFunctionalData object with same argvals as `self`.

        """
        if mean is not None:
            return MultivariateFunctionalData(
                [i.covariance(m, smooth, **kwargs)
                    for i, m in zip(self, mean)])
        else:
            return MultivariateFunctionalData(
                [i.covariance(None, smooth, **kwargs) for i in self])

    def concatenate(self, data):
        """Concatenate two MultivariateFunctionalData.

        Parameters
        ----------
        data: MultivariateFunctionalData
            The data to concatenate with self.

        Returns
        -------
        res: MultivariateFunctionalData
            The concatenation of self and data.

        """
        new = [data1.concatenate(data2) for data1, data2 in zip(self, data)]
        return MultivariateFunctionalData(new)

##############################################################################
# Functional data manipulation

def concatenate_(*data):
    """Concatenate functional data.

    Compute multiple DenseFunctionalData into one. It works with higher
    dimension for the input data.

    Parameters
    ----------
    *data: DenseFunctionalData
        DenseFunctionalData to concatenate.

    Returns
    -------
    data: DenseFunctionalData
        The concatenation of the input data.

    Notes
    -----
    TODO :
    * Add tests, in particular check that the data are compatible.

    """
    new_argvals = data[0].argvals
    new_values = np.vstack([d.values for d in data])
    return DenseFunctionalData(new_argvals, new_values)

def tensor_product_(data1, data2):
    """Compute the tensor product between functional data.

    Compute the tensor product between all the observation of data1 with all
    the observation of data2.

    Parameters
    ----------
    data1: DenseFunctionalData
        First functional data.
    data2: DenseFunctionalData
        Second functional data.

    Returns
    -------
    data: DenseFunctionalData
        The tensor product between data1 and data2. It contains data1.n_obs *
        data2.n_obs observations.

    Notes
    -----
    TODO:
    * Add tests.

    """
    arg = {'input_dim_0': data1.argvals['input_dim_0'],
           'input_dim_1': data2.argvals['input_dim_0']}
    val = [outer_(i, j) for i in data1.values for j in data2.values]
    return DenseFunctionalData(arg, np.array(val))


# -*- coding:utf-8 -*-
import numpy as np
from random import choice

def strategy(state):
    """ Information provided to you:
    state = (board, last_move, playing, board_size)
    board = (x_stones, o_stones)
    stones is a set contains positions of one player's stones. e.g.
        x_stones = {(8,8), (8,9), (8,10), (8,11)}
    playing = 0|1, the current player's index

    Your strategy will return a position code for the next stone, e.g. (8,7)
    """
    board, last_move, playing, board_size = state
    row = board_size
    col = board_size
    # create a table to record the board state
    # 1: occupied by self
    # 2: occupied by opponent
    # 0: available
    table = np.zeros([row, col])
    for i in range(row):
        for j in range(col):
            if playing == 1:
                if (i+1, j+1) in board[0]:
                    table[i, j] = 2
                elif (i+1, j+1) in board[1]:
                    table[i, j] = 1
            else:
                if (i+1, j+1) in board[0]:
                    table[i, j] = 1
                elif (i+1, j+1) in board[1]:
                    table[i, j] = 2
    # 获取该点4个方向的棋型
    def getstring(point):
        x = point[0]
        y = point[1]
        # vertical
        getLine1 = ''
        for k in range(max(x - 4, 0), min(x + 5, 15)):
            getLine1 += str(int(table[k, y]))
        if x-4<0: getLine1 = '*'+getLine1
        if x+4>14: getLine1 = getLine1+'*'
        # horizonal
        getLine2 = ''
        for k in range(max(y - 4, 0), min(y + 5, 15)):
            getLine2 += str(int(table[x, k]))
        if y-4<0: getLine2 = '*'+getLine2
        if y+4>14: getLine2 = getLine2+'*'
        # Oblique 45
        getLine3 = ''
        bx = max(0, x - 4)
        by = max(0, y - 4)
        ux = min(14, x + 4)
        uy = min(14, y + 4)
        for k in range(max(bx - x, by - y), min(ux - x, uy - y)+1):
            getLine3 += str(int(table[x + k, y + k]))
        if x-4<0 or y-4<0: getLine3 = '*'+getLine3
        if x+4>14 or y+4>14: getLine3 = getLine3+'*'
        # Oblique 135
        getLine4 = ''
        for k in range(max(bx - x, y - uy), min(ux - x, y - by)+1):
            getLine4 += str(int(table[x + k, y - k]))
        if x-4<0 or y+4>14: getLine4 = '*'+getLine4
        if x+4>14 or y-4<0: getLine4 = getLine4+'*'

        return [getLine1, getLine2, getLine3, getLine4]

    # 判断我方棋型
    def judgeType1(getline):
        if '11111' in getline:
            return 'win5'
        if '011110' in getline:
            return 'alive4'
        if '211110' in getline or '011112' in getline\
                or '*11110' in getline or '01111*' in getline:
            return 'lian-rush4'
        if '11101' in getline or '10111' in getline\
                or '11011' in getline:
            return 'tiao-rush4'
        if '001110' in getline or '011100' in getline:
            return 'lian-alive3'
        if '011010' in getline or '010110' in getline:
            return 'tiao-alive3'
        if '211100' in getline or '001112' in getline\
                or '*11100' in getline or '00111*' in getline:
            return 'lian-sleep3'
        if '211010' in getline or '010112' in getline\
                or '*11010' in getline or '01011*' in getline\
                or '210110' in getline or '011012' in getline\
                or '*10110' in getline or '01101*' in getline:
            return 'tiao-sleep3'
        if '11001' in getline or '10011' in getline\
                or '10101' in getline:
            return 'te-sleep3'
        if '2011102' in getline or '*011102' in getline\
                or '201110*' in getline or '*01110*' in getline:
            return 'jia-alive3'
        if '001100' in getline or '011000' in getline\
                or '000110' in getline or '001010' in getline\
                or '010100' in getline or '010010' in getline:
            return 'alive2'
        if '211000' in getline or '000112' in getline\
                or '*11000' in getline or '00011*' in getline\
                or '210100' in getline or '001012' in getline\
                or '*10100' in getline or '00101*' in getline\
                or '210010' in getline or '010012' in getline\
                or '*10010' in getline or '01001*' in getline\
                or '10001' in getline or '2010102' in getline\
                or '*01010*' in getline or '201010*' in getline\
                or '*010102' in getline or '2011002' in getline\
                or '2001102' in getline or '*011002' in getline\
                or '200110*' in getline or '201100*' in getline\
                or '*001102' in getline:
            return 'sleep2'
        if '010' in getline:
            return 'alive1'
        else:
            return 'nothreat'

    # 判断对方棋型
    def judgeType2(getline):
        if '22222' in getline:
            return 'win5'
        if '022220' in getline:
            return 'alive4'
        if '122220' in getline or '022221' in getline\
                or '*22220' in getline or '02222*' in getline:
            return 'lian-rush4'
        if '22202' in getline or '20222' in getline\
                or '22022' in getline:
            return 'tiao-rush4'
        if '002220' in getline or '022200' in getline:
            return 'lian-alive3'
        if '022020' in getline or '020220' in getline:
            return 'tiao-alive3'
        if '122200' in getline or '002221' in getline\
                or '*22200' in getline or '00222*' in getline:
            return 'lian-sleep3'
        if '122020' in getline or '020221' in getline\
                or '*22020' in getline or '02022*' in getline\
                or '120220' in getline or '022021' in getline\
                or '*20220' in getline or '02202*' in getline:
            return 'tiao-sleep3'
        if '22002' in getline or '20022' in getline\
                or '20202' in getline:
            return 'te-sleep3'
        if '1022201' in getline or '*022201' in getline\
                or '102220*' in getline or '*02220*' in getline:
            return 'jia-alive3'
        if '002200' in getline or '022000' in getline\
                or '000220' in getline or '002020' in getline\
                or '020200' in getline or '020020' in getline:
            return 'alive2'
        if '122000' in getline or '000221' in getline\
                or '*22000' in getline or '00022*' in getline\
                or '120200' in getline or '002021' in getline\
                or '*20200' in getline or '00202*' in getline\
                or '120020' in getline or '020021' in getline\
                or '*20020' in getline or '02002*' in getline\
                or '20002' in getline or '1020201' in getline\
                or '*02020*' in getline or '102020*' in getline\
                or '*020201' in getline or '1022001' in getline\
                or '1002201' in getline or '*022001' in getline\
                or '100220*' in getline or '102200*' in getline\
                or '*002201' in getline:
            return 'sleep2'
        if '020' in getline:
            return 'alive1'
        else:
            return 'nothreat'

    # 计算我方形势分数
    def evaluate_self(table):
        row, col = table.shape
        myscore = 0
        for i in range(row):
            for j in range(col):
                if table[i, j] == 1:
                    point = (i, j)
                    myType={'win5':0, 'alive4':0, 'lian-rush4':0, 'tiao-rush4':0, 'lian-alive3':0, 'tiao-alive3':0,\
                            'lian-sleep3':0, 'tiao-sleep3':0, 'te-sleep3':0, 'jia-alive3':0,\
                            'alive2':0, 'sleep2':0, 'alive1':0, 'nothreat':0}
                    lines = getstring(point)
                    for item0 in lines:
                        tmp1 = judgeType1(item0)
                        myType[tmp1] += 1
                    # my score
                    myscore += 1000000*myType['win5']+20000*myType['alive4']+ \
                               6100*myType['lian-rush4']+6000*myType['tiao-rush4']+ \
                               1100*myType['lian-alive3']+1000*myType['tiao-alive3']+ \
                               300*myType['lian-sleep3']+290*myType['tiao-sleep3']+\
                               290*myType['te-sleep3']+290*myType['jia-alive3']+\
                               100*myType['alive2']+10*myType['sleep2']+\
                               3*myType['alive1']+1*myType['nothreat']
        return myscore

    # 计算敌方的形势分数
    def evaluate_op(table):
        row, col = table.shape
        opscore = 0
        for i in range(row):
            for j in range(col):
                if table[i, j] == 2:
                    point = (i, j)
                    opType = {'win5':0, 'alive4':0, 'lian-rush4':0, 'tiao-rush4':0, 'lian-alive3':0, 'tiao-alive3':0,\
                            'lian-sleep3':0, 'tiao-sleep3':0, 'te-sleep3':0, 'jia-alive3':0,\
                            'alive2':0, 'sleep2':0, 'alive1':0, 'nothreat':0}
                    lines = getstring(point)
                    for item0 in lines:
                        tmp2 = judgeType2(item0)
                        opType[tmp2] += 1
                    # opponent score
                    opscore += 1000000*opType['win5']+100000*opType['alive4']+ \
                               65000*opType['lian-rush4']+65000*opType['tiao-rush4']+ \
                               5500*opType['lian-alive3']+5000*opType['tiao-alive3']+ \
                               200*opType['lian-sleep3']+200*opType['tiao-sleep3']+\
                               200*opType['te-sleep3']+200*opType['jia-alive3']+\
                               90*opType['alive2']+9*opType['sleep2']+\
                               4*opType['alive1']+1*opType['nothreat']
        return opscore

    #随机返回一个score最大的位置
    def randomChoose(scoretable):
        maxValue = max(scoretable.items(), key=lambda x: x[1])[1]
        positions=[]
        for item in scoretable.items():
            if item[1]==maxValue:
                positions.append(item[0])
        return choice(positions)

    if len(board[0]) == 0 and len(board[1]) == 0:
        return (board_size/2 + 1, board_size/2 + 1)
    else:
        # 获得局部搜索区域的下标
        sumBoard = board[0] | board[1]
        xmax = min(max(sumBoard, key=lambda x: x[0])[0] + 2, 15)
        ymax = min(max(sumBoard, key=lambda x: x[1])[1] + 2, 15)
        xmin = max(min(sumBoard, key=lambda x: x[0])[0] - 3, 0)
        ymin = max(min(sumBoard, key=lambda x: x[1])[1] - 3, 0)

        scoretable={}
        for i in range(xmin, xmax):
            for j in range(ymin, ymax):
                if table[i, j] == 0:
                    #old = evaluate_self(table)-defend*evaluate_op(table)
                    table[i, j] = 1
                    scoretable[(i, j)] = evaluate_self(table)-evaluate_op(table)
                    table[i, j] = 0
        self_position = randomChoose(scoretable)
        return (self_position[0]+1, self_position[1]+1)

def finish():
    pass


import numpy as np
import matplotlib.pyplot as plt
import matplotlib.colors as colors
import matplotlib.cm as cm
import pandas as pd
import re
import io
import os
import astropy.constants as cst
import astropy.units as units
import scipy.interpolate as interp

from collections import deque
from pathlib import Path

#######################################
# model loading functions
#
def _read_model_BHAC2015(path, fname, instrument):
    '''
    (Private) Read the BHAC2015 models

    Parameters
    ----------
    path : str
        Full path to the directory containing the model files

    fname : str
        Full model file name

    instrument : str
        Name of the instrument (or observatory) for the file

    Returns
    -------
    masses : vector
        Numpy vector with unique masses, in MJup

    ages : vector
        Numpy vector with unique ages, in Myr

    values : array
        Array with names of parameters

    data : array
        Numpy data array
    '''

    # read general data
    data = pd.read_csv(path / fname, sep='\s+', header=None, comment='!')

    # add ages
    data.insert(0, 'age', 0)

    # read column headers and age values
    p_cols = re.compile('!\s+(mass)\s+(Teff)')
    p_ages = re.compile('!\s+t\s+\(Gyr\)\s+=\s+([0-9]+\.[0-9]+)')
    p_vals = re.compile('\s+([0-9]+\.[0-9]+)\s+([0-9]+\.[0-9]+)')

    cols = ['age']
    ages = []
    cage = 0

    file = open(path / fname, 'r')
    for line in file:
        # skip non-comment lines
        if (line[0] != '!'):
            m = p_vals.match(line)
            if (m is not None):
                ages.append(cage)
            continue

        # column names
        if (len(cols) == 1):
            m = p_cols.match(line)
            if (m is not None):
                cols.extend(line[1:].split())

        # age value
        m = p_ages.match(line)
        if (m is not None):
            cage = float(m.group(1))

    file.close()

    # rename columns and add age values
    data.columns = cols
    data.age = ages

    # unit conversion
    data.age    *= 1000
    data.mass   *= cst.M_sun / cst.M_jup
    data.radius *= cst.R_sun / cst.R_jup

    # reshape in final format
    masses, ages, values, dat = _reshape_data(data)

    return masses, ages, values, dat

def _read_model_PHOENIX_websim(path, fname, instrument):
    '''
    (Private) Read models from the PHOENIX web simulator

    Parameters
    ----------
    path : str
        Full path to the directory containing the model files

    fname : str
        Full model file name

    instrument : str
        Name of the instrument (or observatory) for the file

    Returns
    -------
    masses : vector
        Numpy vector with unique masses, in MJup

    ages : vector
        Numpy vector with unique ages, in Myr

    values : array
        Array with names of parameters

    data : array
        Numpy data array
    '''

    # read column headers and number of values
    p_cols = re.compile('\s+M/Ms\s*Teff.K.\s+L/Ls\s+lg\(g\)\s+R.(\w+).\s+D\s+Li\s+([A-Za-z0-9\\s_.\']+)')
    p_ages = re.compile('\s+t\s+\(Gyr\)\s+=\s+([0-9]+\.[0-9]+)')
    p_vals = re.compile('(\s+[+-]*[0-9]+\.[0-9]*){3}')

    cols  = ['age', 'mass', 'Teff', 'logL', 'logg', 'radius', 'D', 'Li']
    cage  = 0
    ages  = []
    unit  = None
    lines = []

    # get column names
    file = open(path / fname, 'r')
    for line in file:
        # age value
        m = p_ages.match(line)
        if (m is not None):
            cage = float(m.group(1))
            continue

        # column names
        if (len(cols) == 8):
            m = p_cols.match(line)
            if (m is not None):
                unit = m.group(1)

                names = m.group(2)
                names = names.replace("'", "p")

                cols.extend(names.split())

                continue

        # model values
        m = p_vals.match(line)
        if (m is not None):
            lines.append(line)
            ages.append(cage)

    file.close()

    # create data frame
    lines = ''.join(lines)
    data = pd.read_csv(io.StringIO(lines), sep='\s+', header=None)

    # add ages
    data.insert(0, 'age', 0)
    data.age = ages

    # rename columns
    data.columns = cols

    # unit conversion
    data.age  *= 1000
    data.mass *= cst.M_sun / cst.M_jup
    if unit == 'Gm':
        # data.radius /= cst.R_jup.to(units.Gm)
        pass
    elif unit == 'Gcm':
        #data.radius /= cst.R_jup.to(units.Gm*100)
        pass
    else:
        pass

    # reshape in final format
    masses, ages, values, dat = _reshape_data(data)

    return masses, ages, values, dat

def _read_model_sonora(path, fname, instrument):
    '''
    (Private) Read the SONORA models

    Parameters
    ----------
    path : str
        Full path to the directory containing the model files

    fname : str
        Full model file name

    instrument : str
        Name of the instrument (or observatory) for the file

    Returns
    -------
    masses : vector
        Numpy vector with unique masses, in MJup

    ages : vector
        Numpy vector with unique ages, in Myr

    values : array
        Array with names of parameters

    data : array
        Numpy data array
    '''

    df = pd.read_csv(path / fname, index_col=(0, 1))

    masses  = np.sort(np.unique(df.index.get_level_values(0)))  # MJup
    ages    = np.sort(np.unique(df.index.get_level_values(1)))  # yr
    values  = df.columns

    data = np.zeros((len(masses), len(ages), len(values)))
    for iv, val in enumerate(values):
        for im, mass in enumerate(masses):
            tmp = df.loc[(mass, slice(None)), val]
            data[im, :, iv] = tmp

    # converts ages in Myr
    ages = ages / 1e6

    return masses, ages, values, data

def _read_model_bex(path, fname, instrument):
    '''
    (Private) Read the BEX models

    Parameters
    ----------
    path : str
        Full path to the directory containing the model files

    fname : str
        Full model file name

    instrument : str
        Name of the instrument (or observatory) for the file

    Returns
    -------
    masses : vector
        Numpy vector with unique masses, in MJup

    ages : vector
        Numpy vector with unique ages, in Myr

    values : array
        Array with names of parameters

    data : array
        Numpy data array
    '''

    df = pd.read_csv(path / fname, index_col=(0, 1))

    masses  = np.sort(np.unique(df.index.get_level_values(0)))  # MJup
    ages    = np.sort(np.unique(df.index.get_level_values(1)))  # yr
    values  = df.columns

    data = np.zeros((len(masses), len(ages), len(values)))
    for iv, val in enumerate(values):
        for im, mass in enumerate(masses):
            tmp = df.loc[(mass, slice(None)), val]
            data[im, :, iv] = tmp

    # converts ages in Myr
    ages = ages / 1e6

    return masses, ages, values, data

def _read_model_atmo(path, fname, instrument):
    '''
    (Private) Read the ATMO models

    Parameters
    ----------
    path : str
        Full path to the directory containing the model files

    fname : str
        Full model file name

    instrument : str
        Name of the instrument (or observatory) for the file

    Returns
    -------
    masses : vector
        Numpy vector with unique masses, in MJup

    ages : vector
        Numpy vector with unique ages, in Myr

    values : array
        Array with names of parameters

    data : array
        Numpy data array
    '''

    df = pd.read_csv(path / fname, index_col=(0, 1))

    masses  = np.sort(np.unique(df.index.get_level_values(0)))  # MJup
    ages    = np.sort(np.unique(df.index.get_level_values(1)))  # yr
    values  = df.columns

    data = np.zeros((len(masses), len(ages), len(values)))
    for iv, val in enumerate(values):
        for im, mass in enumerate(masses):
            tmp = df.loc[(mass, slice(None)), val]
            data[im, :, iv] = tmp

    # converts ages in Myr
    ages = ages / 1e6

    return masses, ages, values, data

def _reshape_data(dataframe):
    '''
    Reshape the data frame in a regular grid that can be used as input in scipy functions.

    Parameters
    ----------
    dataframe : pandas DataFrame
        Data frame with all the data

    Returns
    -------
    masses : vector
        Numpy vector with unique masses, in MJup

    ages : vector
        Numpy vector with unique ages, in Myr

    values : array
        Array with names of parameters

    data : array
        Numpy data array
    '''

    # unique ages and masses
    masses = dataframe.mass.unique()
    ages = dataframe.age.unique()

    # values
    values = dataframe.columns.values[2:]

    # fill array
    data = np.full((masses.size, ages.size, values.size), np.nan)
    for m, mass in enumerate(masses):
        for a, age in enumerate(ages):
            mask = (dataframe.mass == mass) & (dataframe.age == age)

            if mask.any():
                data[m, a, :] = dataframe.loc[mask, 'Teff':].values.squeeze()

    return masses, ages, values, data

#######################################
# utility functions
#
def _monotonic_sublists(lst):
    '''
    (Private) Extract monotonic sublists from a list of values

    Given a list of values that is not sorted (such that for some valid
    indices i,j, i<j, sometimes lst[i] > lst[j]), produce a new
    list-of-lists, such that in the new list, each sublist *is*
    sorted: for all sublist \elem returnval: assert_is_sorted(sublist)
    and furthermore this is the minimal set of sublists required to
    achieve the condition.

    Thus, if the input list lst is actually sorted, this returns
    [list(lst)].

    Parameters
    ----------
    lst : list or array
        List of values

    Returns
    -------
    ret_i : list
        List of indices of monotonic sublists

    ret_v : list
        List of values of monotonic sublists
    '''

    # Make a copy of lst before modifying it; use a deque so that
    # we can pull entries off it cheaply.
    idx = deque(range(len(lst)))
    deq = deque(lst)
    ret_i = []
    ret_v = []
    while deq:
        sub_i = [idx.popleft()]
        sub_v = [deq.popleft()]

        if len(deq) > 1:
            if deq[0] <= sub_v[-1]:
                while deq and deq[0] <= sub_v[-1]:
                    sub_i.append(idx.popleft())
                    sub_v.append(deq.popleft())
            else:
                while deq and deq[0] >= sub_v[-1]:
                    sub_i.append(idx.popleft())
                    sub_v.append(deq.popleft())

        ret_i.append(sub_i)
        ret_v.append(sub_v)

    return ret_i, ret_v

def _interpolate_model(masses, ages, values, data, age, filt, param, Mabs, fill):
    '''
    (Private) Interpolate model grid

    Parameters
    ----------
    masses : array
        Mass values in the model grid

    ages : array
        Age values in the model grid

    values : array
        Name of filters (and other parameters) in the model grid

    data : array
        Data of the model grid

    age : float
        Age at which interpolation is needed

    filt : str
        Filter in which the magnitude is provided

    param : str
        Parameter to be interpolated

    Mabs : array
        Absolute magnitude

    fill : bool
        Fill interpolated values with min/max values in the models when
        trying to interpolate outside the values in the models

    Returns
    -------
    values : array
        Interpolated values
    '''

    # age indices
    ii = np.abs(ages-age).argmin()
    if age <= ages[ii]:
        imin = ii-1
        imax = ii
    elif age > ages[ii]:
        imin = ii
        imax = ii+1

    agemin = ages[imin]
    agemax = ages[imax]

    # parameter value
    if param == 'Mass':
        ifilt = np.where(values == filt)[0]

        Zmin = data[:, imin, ifilt].squeeze()
        Zmax = data[:, imax, ifilt].squeeze()

        Znew = (Zmin - Zmax) / (agemin - agemax) * (age - agemin) + Zmin

        # remove missing values
        masses = masses[np.isfinite(Znew)]
        Znew   = Znew[np.isfinite(Znew)]

        # find monotonic parts of the signal
        mono_i, mono_v = _monotonic_sublists(Znew)

        nsub = len(mono_i)
        sub_idx = np.zeros((2*nsub-1, 2), dtype=np.int)
        for s in range(nsub):
            sub_idx[s, 0] = mono_i[s][0]
            sub_idx[s, 1] = mono_i[s][-1]
        for s in range(nsub-1):
            sub_idx[s+nsub, 0] = mono_i[s][-1]
            sub_idx[s+nsub, 1] = mono_i[s+1][0]

        sub_idx = np.sort(sub_idx, axis=0)

        # interpolate over each part
        values = np.zeros((2*nsub-1, Mabs.size))
        for i, s in enumerate(sub_idx):
            sub_Znew   = Znew[s[0]:s[1]+1]
            sub_masses = masses[s[0]:s[1]+1]

            if len(sub_Znew) < 2:
                continue

            interp_func = interp.interp1d(sub_Znew, sub_masses, bounds_error=False, fill_value=np.nan)
            values[i] = interp_func(Mabs)

            # fill if outside of available values
            if fill:
                values[i, Mabs < sub_Znew.min()] = masses.max()
                values[i, Mabs > sub_Znew.max()] = masses.min()

        # combine
        values = np.nanmax(values, axis=0)
    else:
        raise ValueError('Interpolation for parameter {0} is not implemented yet.'.format(param))

    return values

def _read_model_data(paths, models, instrument, model):
    '''
    Return the data from a model and instrument

    Parameters
    ----------
    paths : list
        List of paths where to find the models

    models : dict
        Dictionary containing all the models information and data

    instrument : str
        Instrument name

    model : str
        Model name

    Returns
    -------
    path : str
        The complete path to the model file
    '''

    # lower case
    model = model.lower()
    instrument = instrument.lower()

    # model key
    key = instrument.lower()+'_'+model.lower()

    # find proper model
    data = None
    for mod in models['properties']:
        if (mod['name'] == model) and (mod['instrument'] == instrument):
            fname = mod['file']

            # search for path
            found = False
            for path in paths:
                if (path / fname).exists():
                    mod['path'] = path
                    found = True
                    break

            if not found:
                raise ValueError('File {0} for model {1} and instrument {2} does not exists. Are you sure it is in your search path?'.format(path, model, instrument))

            # get data in format (masses, ages, values, data)
            data = mod['function'](path, fname, instrument)

    # not found
    if data is None:
        raise ValueError('Could not find model {0} for instrument {1}'.format(model, instrument))

    # save data
    models['data'][key] = data

#######################################
# models definitions
#
search_path = [(Path(__file__) / '../../data/evolution/').resolve()]
models = {
    'properties': [
        {'instrument': 'nicmos', 'name': 'dusty2000',           'file': 'model.AMES-dusty-2000.M-0.0.HST',           'function': _read_model_PHOENIX_websim},
        {'instrument': 'naco',   'name': 'dusty2000',           'file': 'model.AMES-dusty-2000.M-0.0.NaCo',          'function': _read_model_PHOENIX_websim},
        {'instrument': 'irdis',  'name': 'dusty2000',           'file': 'model.AMES-dusty-2000.M-0.0.SPHERE.Vega',   'function': _read_model_PHOENIX_websim},
        {'instrument': 'nicmos', 'name': 'cond2003',            'file': 'model.AMES-Cond-2003.M-0.0.HST',            'function': _read_model_PHOENIX_websim},
        {'instrument': 'naco',   'name': 'cond2003',            'file': 'model.AMES-Cond-2003.M-0.0.NaCo',           'function': _read_model_PHOENIX_websim},
        {'instrument': 'irdis',  'name': 'cond2003',            'file': 'model.AMES-Cond-2003.M-0.0.SPHERE.Vega',    'function': _read_model_PHOENIX_websim},
        {'instrument': 'irdis',  'name': 'bhac2015+dusty2000',  'file': 'BHAC15_DUSTY00_iso_t10_10.SPHERE',          'function': _read_model_BHAC2015},
        {'instrument': 'irdis',  'name': 'bhac2015+cond2003',   'file': 'BHAC15_COND03_iso_t10_10.SPHERE',           'function': _read_model_BHAC2015},

        {'instrument': 'mko',    'name': 'sonora',              'file': 'sonora_mko.csv.gz',                         'function': _read_model_sonora},
        {'instrument': '2mass',  'name': 'sonora',              'file': 'sonora_2mass.csv.gz',                       'function': _read_model_sonora},
        {'instrument': 'keck',   'name': 'sonora',              'file': 'sonora_keck.csv.gz',                        'function': _read_model_sonora},
        {'instrument': 'sdss',   'name': 'sonora',              'file': 'sonora_sdss.csv.gz',                        'function': _read_model_sonora},
        {'instrument': 'irac',   'name': 'sonora',              'file': 'sonora_irac.csv.gz',                        'function': _read_model_sonora},
        {'instrument': 'wise',   'name': 'sonora',              'file': 'sonora_wise.csv.gz',                        'function': _read_model_sonora},

        {'instrument': 'irdis',  'name': 'bex_cond_coldest',    'file': 'bex_ames-cond_coldest.csv.gz',              'function': _read_model_bex},
        {'instrument': 'irdis',  'name': 'bex_cond_warm',       'file': 'bex_ames-cond_warm.csv.gz',                 'function': _read_model_bex},
        {'instrument': 'irdis',  'name': 'bex_cond_hot',        'file': 'bex_ames-cond_hot.csv.gz',                  'function': _read_model_bex},
        {'instrument': 'irdis',  'name': 'bex_cond_hottest',    'file': 'bex_ames-cond_hottest.csv.gz',              'function': _read_model_bex},
        {'instrument': 'irdis',  'name': 'bex_dusty_coldest',   'file': 'bex_ames-dusty_coldest.csv.gz',             'function': _read_model_bex},
        {'instrument': 'irdis',  'name': 'bex_dusty_warm',      'file': 'bex_ames-dusty_warm.csv.gz',                'function': _read_model_bex},
        {'instrument': 'irdis',  'name': 'bex_dusty_hot',       'file': 'bex_ames-dusty_hot.csv.gz',                 'function': _read_model_bex},
        {'instrument': 'irdis',  'name': 'bex_dusty_hottest',   'file': 'bex_ames-dusty_hottest.csv.gz',             'function': _read_model_bex},

        {'instrument': 'mko',    'name': 'atmo_ceq',            'file': 'ATMO_CEQ_MKO.csv.gz',                       'function': _read_model_atmo},
        {'instrument': 'mko',    'name': 'atmo_neq_strong',     'file': 'ATMO_NEQ_strong_MKO.csv.gz',                'function': _read_model_atmo},
        {'instrument': 'mko',    'name': 'atmo_neq_weak',       'file': 'ATMO_NEQ_weak_MKO.csv.gz',                  'function': _read_model_atmo},
        {'instrument': 'irac',   'name': 'atmo_ceq',            'file': 'ATMO_CEQ_MKO.csv.gz',                       'function': _read_model_atmo},
        {'instrument': 'irac',   'name': 'atmo_neq_strong',     'file': 'ATMO_NEQ_strong_MKO.csv.gz',                'function': _read_model_atmo},
        {'instrument': 'irac',   'name': 'atmo_neq_weak',       'file': 'ATMO_NEQ_weak_MKO.csv.gz',                  'function': _read_model_atmo},
        {'instrument': 'wise',   'name': 'atmo_ceq',            'file': 'ATMO_CEQ_MKO.csv.gz',                       'function': _read_model_atmo},
        {'instrument': 'wise',   'name': 'atmo_neq_strong',     'file': 'ATMO_NEQ_strong_MKO.csv.gz',                'function': _read_model_atmo},
        {'instrument': 'wise',   'name': 'atmo_neq_weak',       'file': 'ATMO_NEQ_weak_MKO.csv.gz',                  'function': _read_model_atmo},
    ],
    'data': {}
}

#######################################
# public functions
#
def mag_to_mass(age, distance, mag, Dmag, filt,
                instrument='IRDIS', model='bhac2015+cond2003', fill=False,
                age_range=None, distance_range=None, mag_err=None, Dmag_range=None):
    '''
    Convert a contrast value into mass

    Parameters
    ----------
    age : float
        Age of the target in Myr

    distance : float
        Distance of the target in pc

    mag : float
        Magnitude of the target in the filter

    Dmag : array
        Contrast value(s) in the filter

    filt : str
        Name of the filter

    instrument : str
        Name of the instrument. The default is IRDIS

    model : str
        Name of the evolutionary model. The default is bhac2015+cond2003

    fill : bool
        Fill interpolated values with min/max values in the models when
        trying to interpolate outside the values in the models

    age_range : list
        [min, max] age estimations for the target

    distance_range : list
        [min, max] distance estimations for the target

    mag_err : float
        Error on the target magnitude

    Dmag_range : array
        [min, max] contrast estimations

    Returns
    -------
    mass, mass_min, mass_max : array
        Values of the mass interpolated into the model
    '''

    # -------------------------------
    # get model data
    # -------------------------------
    masses, ages, values, data = model_data(instrument, model)

    # check ages
    if (age < ages.min()) or (age > ages.max()):
        raise ValueError('Age {0} Myr outside of model range [{1}, {2}]'.format(age, ages.min(), ages.max()))

    # check filter
    if filt not in values:
        raise ValueError('Filter {0} not available in list: {1}'.format(filt, values))

    # -------------------------------
    # explicit variable names
    # -------------------------------

    # age range
    if age_range is not None:
        if not isinstance(age_range, list):
            raise ValueError('Age range must be a 2-elements array')

        age_min = np.min(age_range)
        age_max = np.max(age_range)
    else:
        age_min = age
        age_max = age

    # dist range
    if distance_range is not None:
        if not isinstance(distance_range, list):
            raise ValueError('Dist range must be a 2-elements array')

        dist_min = np.min(distance_range)
        dist_max = np.max(distance_range)
    else:
        dist_min = distance
        dist_max = distance

    # Stellar mag range
    if mag_err is not None:
        if not isinstance(mag_err, (int, float)):
            raise ValueError('Stellar mag error must be a float')

        mag_min = mag - mag_err
        mag_max = mag + mag_err
    else:
        mag_min = mag
        mag_max = mag

    # delta mag range
    if Dmag_range is not None:
        raise ValueError('Dmag error not implemented')
    else:
        Dmag_faint  = Dmag
        Dmag_bright = Dmag

    # -------------------------------
    # absolute magnitude conversion
    # -------------------------------

    # nominal values
    Mabs_nom = mag - 5*np.log10(distance) + 5 + Dmag

    # taking errors into account
    Mabs_faint  = mag_min - 5*np.log10(dist_min) + 5 + Dmag_faint
    Mabs_bright = mag_max - 5*np.log10(dist_max) + 5 + Dmag_bright

    # -------------------------------
    # interpolate models
    # -------------------------------
    param = 'Mass'   # only parameter currently available
    values_nom = _interpolate_model(masses, ages, values, data, age, filt, param, Mabs_nom, fill)
    values_min = _interpolate_model(masses, ages, values, data, age_min, filt, param, Mabs_faint, fill)
    values_max = _interpolate_model(masses, ages, values, data, age_max, filt, param, Mabs_bright, fill)

    values_all = np.vstack((values_min, values_nom, values_max))
    values_min = np.nanmin(values_all, axis=0)
    values_max = np.nanmax(values_all, axis=0)

    return values_nom, values_min, values_max

def list_models():
    '''
    Print the list of available models
    '''
    print()
    print('Search paths:')
    for p in search_path:
        print(' * {}'.format(p))
    print()

    for i in range(len(models['properties'])):
        prop = models['properties'][i]

        print(prop['file'])
        print(' * instrument: {0}'.format(prop['instrument']))
        print(' * name:       {0}'.format(prop['name']))
        print(' * function:   {0}'.format(prop['function'].__name__))
        try:
            print(' * path:       {0}'.format(prop['path']))
        except KeyError:
            pass
        print()

def model_data(instrument, model):
    '''
    Return the model data for a given instrument

    Directly returns the data if it has been read and stored
    already. Otherwise read and store it before returning.

    Parameters
    ----------
    instrument : str
        Instrument name

    model : str
        Model name

    Returns
    -------
    data : tuple
        Tuple (masses, ages, values, data)
    '''

    # model key
    key = instrument.lower()+'_'+model.lower()

    if key not in models['data'].keys():
        print('Loading model {0} for {1}'.format(model, instrument))

        _read_model_data(search_path, models, instrument, model)

    return models['data'][key]

def add_search_path(path):
    '''
    Add a new location in the search path

    Useful to easily handle "private" models that are not provided
    with the public distribution of the package.

    Parameters
    ----------
    path : str
        Path to the additional directory
    '''

    path = Path(path).expanduser().resolve()

    # add only if necessary
    if path not in search_path:
        search_path.append(path)

def plot_model(instrument, model, param, age_list=None, mass_list=None):
    '''
    Plot parameter evolution as a function of age for a model and instrument

    Parameters
    ----------
    instrument : str
        Instrument name

    model : str
        Model name

    param : str
        Parameter of the model to be plotted

    age_list : array
        List of ages to use for the plots. Default is None, so it will
        use all available ages

    mass_list : array
        List of masses to use for the plots. Default is None, so it will
        use all available masses

    Returns
    -------
    path : str
        The complete path to the model file
    '''

    masses, ages, values, data = model_data(instrument, model)

    if not mass_list:
        mass_list = masses

    if not age_list:
        age_list = ages

    cmap = cm.plasma
    norm = colors.LogNorm(vmin=ages.min(), vmax=ages.max())

    #
    # param vs. age
    #
    fig = plt.figure(0, figsize=(12, 9))
    plt.clf()
    ax = fig.add_subplot(111)

    for mass in mass_list:
        if (mass <= 75):
            ax.plot(ages, data[masses == mass, :, values == param].squeeze(),
                    label=r'{0:.1f} MJup'.format(mass), color=cmap(mass/75.))

    ax.set_xscale('log')
    ax.set_yscale('linear')

    ax.set_xlabel('Age [Myr]')
    ax.set_ylabel(param)

    ax.set_title('{0}, {1}'.format(model, instrument))

    ax.legend(loc='upper right')

    plt.tight_layout()

    #
    # param vs. mass
    #
    fig = plt.figure(1, figsize=(12, 9))
    plt.clf()
    ax = fig.add_subplot(111)

    for age in age_list:
        ax.plot(masses, data[:, ages == age, values == param].squeeze(),
                label=r'{0:.4f} Myr'.format(age), color=cmap(norm(age)))

    ax.set_xlim(0, 75)

    ax.set_xscale('linear')
    ax.set_yscale('linear')

    ax.set_xlabel(r'Mass [$M_{Jup}$]')
    ax.set_ylabel(param)

    ax.set_title('{0}, {1}'.format(model, instrument))

    # ax.legend(loc='upper right')

    plt.tight_layout()

import numpy,import scipy,import astropy