import matplotlib.colors as colors
import matplotlib.cm as cmx
from matplotlib import pyplot as plt
import numpy as np
import pandas as pd
import math
import scipy.stats 
from scipy.optimize import least_squares
import scipy.stats



######################################################################################################
######################################################################################################
######################################################################################################
######################################################################################################
######################################################################################################
######################################## SECTION 1: Functions ########################################
######################################## SECTION 1: Functions ########################################
######################################## SECTION 1: Functions ########################################
######################################## SECTION 1: Functions ########################################
######################################## SECTION 1: Functions ########################################
######################################################################################################
######################################################################################################
######################################################################################################
######################################################################################################
######################################################################################################
######################################################################################################
######################################################################################################

def make_pretty(ax, title='', xTitle='', yTitle=''):
    '''
    Purpose: This function makes a few basic cosmetic adjustments to an input axes object.
             The objective is to serve as a "quick and dirty" way to make a plot more presentable.

    Input:
        - ax     (matplotlib axes object) : The axes object to which cosmetic adjustments will be made
        - title  (str)                    : Desired plot title
        - xTitle (str)                    : Desired x-axis label
        - yTitle (str)                    : Desired y-axis label

    Output: 
        - There is no output. The orginal copy of ax is altered directly.

    '''
    ax.spines['left'].set_color('black')
    ax.spines['top'].set_color('black')
    ax.spines['bottom'].set_color('black')
    ax.spines['right'].set_color('black')

    ax.spines['left'].set_linewidth(2)
    ax.spines['top'].set_linewidth(2)
    ax.spines['bottom'].set_linewidth(2)
    ax.spines['right'].set_linewidth(2)
    
    ax.set_title(title, fontsize = 20)
    ax.set_xlabel(xTitle, fontsize=16)
    ax.yaxis.set_label_coords(-0.15, 0.5)
    ax.set_ylabel(yTitle, rotation = 'horizontal', fontsize = 16)
    
    ax.tick_params(axis='both', labelsize = 16)
    
    ax.grid(True)
    return


def scatter_bygroup(ax, x,y,col,colmap='hot'):
    '''
    Purpose:
        - Plot y against x in a scatterplot, where the points are coloured by a third variable, col.

    Input:
        - ax  (matplotlib axis) : The axis on which to add the scatter plot
        - x   (array/float)     : The data for the x-axis of the scatterplot
        - y   (array/float)     : The data for the y-axis of the scatterplot
        - col (list)            : A list of values of some variable which will correspond to groups. This 
                                  value is used to determine the colour of a point. Points with the same 
                                  'col' value will have the same colour.
        - colmap (str)          : A colormap for determining the group colours. Default to 'hot'.

   Output:
	- Note that there is no output, however, the original input axes object will be altered.
 
    '''
    # Set the color map to match the number of species
    uniq = list(set(col))
    z    = range(1,len(uniq))
   
    cNorm  = colors.Normalize(vmin=0, vmax=len(uniq))
    scalarMap = cmx.ScalarMappable(norm=cNorm, cmap=colmap)

    
    # Plot each group
    for i in range(len(uniq)):
        indx = (col == uniq[i])
        ax.scatter(x[indx], y[indx], s=150, color=scalarMap.to_rgba(i), label=uniq[i])
    return



def moving_average_baseline_subtraction(current, window_size, forward = True):
    '''
    Purpose:
        An iterative function that estimates the baseline of an input signal using a peak-stripping 
        moving average (i.e. a moving average algorithm designed to strip baselines off of peaks.) 
        The function is intended to be used on voltammograms collected via sweep-like voltammetry.

        A simple plot is printed with the baseline estimate overlayed on the input signal.



    Input:
        - current (array/float) : An array of values intended to be a peak-like signal with baseline
        - window_size (int)     : An integer indicating the number of points to be contained in a single window
                                of the moving average
        - forward (boolean)     : This variable is relevant in the context of voltammetric sweep-like data collection.
                                If true, the data in "current" was collected via a sweep-like voltammetric scan where
                                the potential/voltage was swept from a starting value to an increasingly positive
                                potential. If False, the potential/voltage swept from a starting value to increasingly
                                negative values.
                                The objective is essentially to ensure that peak-like structures in the data are "above"
                                the baseline - i.e. more positive than the baseline. If the peaks manifests below the 
                                baseline in your data (i.e. "trench-like" as opposed to peak-like), set this variable to 
                                False, and the algorithm will adjust itself accordingly.
      

  
    Output:
        - baseline (array/float)           : An array of values containing the BASELINE estimate of the algorithm
        - baseline_fig (matplotlib figure) : An overlay plot of the orignal signal and the estimated baseline. Note that
                                             the plot is printed within the function in addition to this output.
    '''
    num_leftover = -1
    
    
    num_samples = len(current)
    num_averages = math.floor(len(current)/window_size)
    
    flag = False # True if the number of samples is a non-integral multiple of window size
    if num_averages*window_size < num_samples:
        flag = True
    
    
    averages = np.zeros(num_averages)
    for i in range(0, num_averages):
        count = i+1
        averages[i] = np.mean((current[(count-1)*window_size:(count*window_size-1)]))
    
    
    # I'm not sure how this is handled in the real Nova algorithm so for now I'm just flagging it.
    if flag:
        num_leftover = len(current) - num_averages*window_size
        
    # If data was obtained on a reverse sweep, we want to read it from right to left for the moving average. We achieve this by flipping the 
    # direction of 'current' below:
    if not forward:
        current = current.iloc[::-1]
   
    temp_averages = averages
    for iteration in range(1000):
        tally = 0
        if forward:
            for i in range(1, num_averages-1):
                # Think about what would happen to an apex in the following: 
                if np.mean((averages[i-1], averages[i+1])) < averages[i]:
                    temp_averages[i] = np.mean((averages[i-1], averages[i+1]))
                    tally += 1
        else:
            for i in range(1, num_averages-1):
                if averages[i] < np.mean((averages[i-1], averages[i+1])):
                    temp_averages[i] = np.mean((averages[i-1], averages[i+1]))
                    tally += 1
            
        
        averages = temp_averages
        if tally == 0:  
            break
        
    # Flip current back, if it is reverse sweep data, since we are done with the moving average portion:
    if not forward:
        current = current.iloc[::-1] 
        
        
    # Interpolate the "betweens":
    baseline = np.zeros(len(current))
    baseline[0] = current[0]
    for i in range(num_averages):
        count = i+1
        baseline[(count*window_size-1)] = averages[i] 
        slope = (averages[i] - averages[i-1])/window_size
        intercept = averages[i-1]
        if i == 0:
            intercept = baseline[0]
            slope = (averages[i] - intercept)/window_size

        start = count*window_size - window_size
        for j in range(1, window_size):            
            baseline[start+j-1] = intercept + slope*j
       
    if flag:
        num_samples = len(current)
        
        start = window_size*num_averages + 1
        
        for i in range(start, num_samples):
            baseline[i] = current[i]
            
            


    
      
        

    return baseline





def get_groupwise_intervals(xdom, yran):
    '''
    Purpose:
        The purpose of this function is to group the input data and then calculate the the means, confidence bands, and prediction bands 
        of each groups. A group is defined by a fixed value of xdom. If many points have the same value of xdom, then they form a group.
        
        The function will output a the interpolated means, confidence interval edges, prediction interval edges, and standard deviations
        of each group. 
        
    Input:
        - xdom (array/float) : an array of points defining the x-coordinates of observations
        - yran (array/float) : an array of points defininf the y-coordinates of observations
        
    Output:
        - outList (list) : list containing 7 arrays
                                1. xUnique    (array/float) : contains the unique xdom values that define the groupings
                                2. means      (array/float) : contains the mean of each group
                                3. sampleSigs (array/float) : the SAMPLE standard deviation of each group (n-1 denominator)
                                4. CILow      (array/float) : lower limits of the CI for the mean of each group
                                5. CIUpp      (array/float) : upper limits of the CI for the mean of each group
                                6. PILow      (array/float) : lower limits of the PI for new observations from each group
                                7. PIUpp      (array/float) : upper limits of the PI for new observations from each group
                                
                        Note that the ith entry in each of the 7 arrays are associated with each other. That is, the ith entry in each array
                        defines a property of the ith group.
                        
                        Note that the limits are calculated at the 95% confidence level and assuming normality of data (i.e. the typical 
                        Student's t multiple of the [group] sample standard deviation. The results may therefore be erroenous for non-normally
                        distributed data.    
    '''

    xUnique = np.unique(xdom) # creates an array of the unique values in xdom
    numGroups = len(xUnique)

    # Create arrays of zeros that will be populated with 
    # the corresponding stats about each group. There is 
    # one entry for each group - i.e. for each unique value
    # of xdom
    means      = np.zeros(numGroups)
    CILow      = np.zeros(numGroups)
    CIUpp      = np.zeros(numGroups)
    PILow      = np.zeros(numGroups)
    PIUpp      = np.zeros(numGroups)
    sampleSigs = np.zeros(numGroups)
    for groupIndex in range(numGroups):
        mask = [val == xUnique[groupIndex] for val in xdom]
        xdat = xdom[mask]
        ydat = yran[mask]


        means[groupIndex] = np.mean(ydat)

        delta = ydat - means[groupIndex]
        sampleSigs[groupIndex] = np.sqrt(np.dot(delta, delta)/(len(delta)-1))

        t = scipy.stats.t.ppf(0.975, len(delta)-1)


        CILow[groupIndex] = means[groupIndex] - t*sampleSigs[groupIndex]/np.sqrt(len(delta))
        CIUpp[groupIndex] = means[groupIndex] + t*sampleSigs[groupIndex]/np.sqrt(len(delta))

        PILow[groupIndex] = means[groupIndex] - t*sampleSigs[groupIndex]*np.sqrt(1+1/len(delta))
        PIUpp[groupIndex] = means[groupIndex] + t*sampleSigs[groupIndex]*np.sqrt(1+1/len(delta))
    
    outList = [xUnique, means, sampleSigs, CILow, CIUpp, PILow, PIUpp]
    return outList


######################################################################################################
######################################################################################################
######################################################################################################
######################################################################################################
######################################################################################################
########################################## SECTION 2: CLASSES ########################################
########################################## SECTION 2: CLASSES ########################################
########################################## SECTION 2: CLASSES ########################################
########################################## SECTION 2: CLASSES ########################################
########################################## SECTION 2: CLASSES ########################################
########################################## SECTION 2: CLASSES ########################################
######################################################################################################
######################################################################################################
######################################################################################################
######################################################################################################
######################################################################################################
######################################################################################################
######################################################################################################


class linear_std_curve:
    '''
    Purpose:
        - To define a linear standard curve complete with prediction intervals, an error propagation formula for use with the delta method, 
          limits of estimation, and details about the creation of the curve, including smoothing parameters, baseline subtraction parameters,
          and calibration procedures.
    
    Attributes:
        - fit_params   (1D array/float) : An array containing the values of the fit coefficients of the linear model. form: [p0, p1], where linear(x) = p0 + p1*x
        - covar        (2D array/float) : the covariance matrix of the fit coefficients
        - s            (float)          : the MSE of the fit to the standard points
        - df           (int)            : the number of degrees of freedom in the standard curve, where the value is equal to the number of standard points minus the number of fit parameters 
        - Elow         (float)          : The lower edge of the peak window for the peak with which the curve was created. The curve is considered inaccurate below this value.
        - Eupp         (float)          : The upper edge of the peak window for the peak with which the curve was created. The curve is considered inaccurate above this value.
        - imin_est     (float)          : minimum current for use in inverse regression. Below this value, the standard curve must be extrapolated, which is poor practice.  
                                          It is the "y" value corresponding to conc_est_min as the "x" value.
        - imax_est     (float)          : maximum current for use in inverse regression. Above this value the standard curve must be extrapolated, which is poor practice.
                                          It is the "y" value corresponding to conc_est_max as the "x" value.
        - imin_std     (float)          : minimum current for obtaining a valid MLE for concentration. It is the "y" value corresponding to conc_std_min as the "x" value
        - imax_std     (float)          : maximum current for obtaining a valid MLE for concentration. It is the "y" value corresponding to conc_std_max as the "x" value
        - conc_est_min (float)          : the lowest possible value of concentration that can be estimated with a valid confidence interval (i.e. the estimate and its 95% limits fall within the non-extrapolated region)
        - conc_est_max (float)          : the largest value of concentratio nthat can be estimated with a valid confidence interval (i.e. the estimate and its 95% limits fall within the non-extrapolated region)
        - conc_std_min (float)          : the lowest concentration of all the std pts. Any MLE above this value and below conc_std_max is a valid MLE. However, the lower limit on the estimate will not be valid
                                          if the MLE is between conc_std_min and conc_est_min (i.e. the lower limit will require extrapolation of the prediction band's upper edge to be calculated... this is risky and is not recommended)
        - conc_std_max (float)          : the largest concentration of all the std pts. Any MLE below this value and above conc_std_min is a valid MLE. However, the upper limit on the estimate will not be valid
                                          if the MLE is between conc_std_ax and conc_est_max (i.e. the upper limit will require extrapolation of the prediction band's lower edge to be calculated... this is risky and is not recommended)
      
        - smoothing    (str)            : details regarding the smoothing procedure used when building the curve
        - baseline     (str)            : details regarding the baseline procedure used when building the curve  
        - calibration  (str)            : details regarding the calibration procedure used when building the curve
   
    Methods/Functions:
        - fit             : performs least-squares regression using scipy.optimize.curve_fit. The function returns estimated fit coefficients, their covariance matrix,
                            and also populate various attributes of the linear_std_curve object including s, standard range, estimation range.
        - predict         : returns the value, y, of the standard curve at a particular input, x. This is essentially the MLE for the system response to the input, x.
        - details         : prints out a summary of the details of the standard curve
        - errprop         : the error propagation formula for a parabola. This method returns the value of the variance of the response variable, y, at a given input, x.
        - upper_pred_edge : returns the value of the upper edge of the prediction interval, with confidence alpha specified by the user, at a particular input, x.
                            This calculation makes use of the 'Delta Method' and uses the errprop method above within its calculation.
        - lower_pred_edge : analogous to upper_pred_edge but for the lower edge of the prediction interval
        - conc_est_min_loss : This function returns zero when the horizontal minimum current line intersects the best fit line. The concentration at the point of intersection is the 
                              minimum concentration estimate that can be reported with a valid lower limit. We solve for the point of intersection via least-squares minimization
                              and use the result to instantiate the conc_est_min parameter of the curve object
        - conc_est_max_loss : This function returns zero when the horizontal maximum current line intersects the best fit line. The concentration at the point of intersection is the 
                              maximum concentration estimate that can be reported with a valid upper limit. We solve for the point of intersecgtions via least-squares minimization
                              and use the result to instatiate the conc_est_max parameter of the curve object.
        - set_params        : This function can be used to manually adjust the values of the curve's parameters. The user will be queried with a simple i/o question & answer algorithm
                              where the user is prompeted to enter each parameter, one after the other.
    '''
               
    # How an instance of the class is initiated:
    def __init__(self, fit_params = np.array([0.,0.]), covar=np.zeros((2,2)), s=0., df=0, 
                 Elow=-2., Eupp=2., imin_est=0., imax_est=100., imin_std=0., imax_std=100,
                 conc_est_min=0.1, conc_est_max=5., conc_std_min=0.1, conc_std_max=100., 
                 smoothing='None', baseline='None', calibration='None'):

        self.fit_params     = fit_params
        self.covar          = covar
        self.s              = s
        self.df             = df
        self.Elow           = Elow
        self.Eupp           = Eupp
        self.imin_std       = imin_std
        self.imax_std       = imax_std
        self.imin_est       = imin_est
        self.imax_est       = imax_est
        self.conc_est_min   = conc_est_min
        self.conc_est_max   = conc_est_max
        self.conc_std_min   = conc_std_min
        self.conc_std_max   = conc_std_max

        
        
        self.smoothing   = smoothing
        self.baseline    = baseline
        self.calibration = calibration
        

        return
    

    
    
    def fit(self, x_data, y_data):
        def linear_map(x, p0=0, p1=1):
            '''
            Purpose: 
                Map an argument, x, to a response, y, with a linear relationship given by:   f(x) = y = p0 + p1*x

            Input:
                - x         (float - array) : The argument for the linear mapping
                - p1        (float)         : the slope in the linear model
                - p0        (float)         : the intercept in the linear model

            Output:
                - y         (float)         : The response of the argument, x, under the linear model
            '''

            y = p0 + p1*x
            return y

        
        
        
        from scipy.optimize import curve_fit
        model          = linear_map
        bestest, covar = curve_fit(model, x_data, y_data)
        ddof           = len(bestest)
        
        self.fit_params = bestest
        self.covar      = covar
        self.df         = len(x_data) - ddof

        
        predictions = self.predict(x_data)
        sse = np.dot(predictions - y_data,predictions - y_data)
            

        mse  = sse/(len(y_data)-ddof)
        rmse = np.sqrt(mse)
        
        self.s            = rmse        
        self.conc_std_min = min(x_data)
        self.conc_std_max = max(x_data)
        
        
        
        # Below, we check if the linear fit slopes positively or negatively. The "standard scenario"
        # is where the slope is positive. If the slope is positive, then the lowest concentration
        # produces the lowest prediction. 
        a                 = self.predict(self.conc_std_min)
        b                 = self.predict(self.conc_std_max)
        standard_scenario = self.fit_params[1] >= 0
        if standard_scenario:
            self.imin_std = a
            self.imax_std = b
        else:
            self.imax_std = a
            self.imin_std = b
         
        
        if standard_scenario:
            self.imin_est     = self.upper_pred_edge(self.conc_std_min, 0.05)
            self.imax_est     = self.lower_pred_edge(self.conc_std_max, 0.05)

        else:
            self.imin_est     = self.upper_pred_edge(self.conc_std_max, 0.05)
            self.imax_est     = self.lower_pred_edge(self.conc_std_min, 0.05)
 
        if standard_scenario:
            self.conc_est_min = least_squares(self.conc_est_min_loss, bounds = (self.conc_std_min, self.conc_std_max), x0 = (self.conc_std_min+self.conc_std_max)/2).x[0]
            self.conc_est_max = least_squares(self.conc_est_max_loss, bounds = (self.conc_std_min, self.conc_std_max), x0 = (self.conc_std_min+self.conc_std_max)/2).x[0]
        else:
            self.conc_est_max = least_squares(self.conc_est_min_loss, bounds = (self.conc_std_min, self.conc_std_max), x0 = (self.conc_std_min+self.conc_std_max)/2).x[0]
            self.conc_est_min = least_squares(self.conc_est_max_loss, bounds = (self.conc_std_min, self.conc_std_max), x0 = (self.conc_std_min+self.conc_std_max)/2).x[0]
        
        
        return bestest, covar, sse
        
        
        
        
        return params, loss
    # Function for printing out the attributes of the object
    def details(self):
        print('Model : linear         --- y = p0 + p1*x')                                                                           
        print('[p0, p1] = {}'.format(self.fit_params))        
        print('--------------------------------------------------')                            
        print('covar :')
        print(self.covar)
        print('--------------------------------------------------')
        print('s                              = {}'.format(self.s))
        print('degrees of freedom             : {}'.format(self.df))
        print('Elow                           = {}'.format(self.Elow))
        print('Eupp                           = {}'.format(self.Eupp))
        print('imin_est                       = {}'.format(self.imin_est))
        print('imax_est                       = {}'.format(self.imax_est))
        print('imin_std                       = {}'.format(self.imin_std))
        print('imax_std                       = {}'.format(self.imax_std))
        print('conc_est_min                   = {}'.format(self.conc_est_min))
        print('conc_est_max                   = {}'.format(self.conc_est_max))
        print('conc_std_min                   = {}'.format(self.conc_std_min))
        print('conc_std_max                   = {}'.format(self.conc_std_max))
        
        print('Smoothing Procedure            : {}'.format(self.smoothing))
        print('Baseline Procedure             : {}'.format(self.baseline))
        print('Internal Calibration Procedure : {}'.format(self.calibration))
        return
    
    
    def set_params(self):
        # set the fit params:
        print('Model : linear         --- y = p0 + p1*x') # notifies the user of the function definition, implying the parameter notation
        for i in range(len(self.fit_params)):
            print('Enter p{} : '.format(i))
            self.fit_params[i] = float(input())
               
        covarShape = self.covar.shape
        for i in range(covarShape[0]):
            #start from i because the matrix is symmetrical:
            for j in range(i, covarShape[1]):
                self.covar[i][j] = float(input('Enter V{}{}'.format(i, j)))
                
        self.s = float(input('Enter model error, s: '))
        self.df = int(input('Enter the number of degrees of freedom of the model : '))

        self.Elow = float(input('Enter Elow : '))
        self.Eupp = float(input('Enter Eupp : '))
        self.conc_std_min = float(input('Enter conc_std_min : '))
        self.conc_std_max = float(input('Enter conc_std_max : '))

        self.smoothing    = str(input('Enter smoothing procedure abbreviation : '))
        self.baseline     = str(input('Enter baseline procedure abbreviation : '))
        self.calibration  = str(input('Enter calibration procedure abbreviation : '))
        
        a = self.predict(self.conc_std_min)
        b = self.predict(self.conc_std_max)
        if a<b:
            self.imin_std = a
            self.imax_std = b
        else:
            self.imax_std = a
            self.imin_std = b
         
        
        if a<b:
            self.imin_est     = self.upper_pred_edge(self.conc_std_min, 0.05)
            self.imax_est     = self.lower_pred_edge(self.conc_std_max, 0.05)
        else:
            self.imin_est     = self.upper_pred_edge(self.conc_std_max, 0.05)
            self.imax_est     = self.lower_pred_edge(self.conc_std_min, 0.05)
 
        if a<b:
            self.conc_est_min = least_squares(self.conc_est_min_loss, bounds = (self.conc_std_min, self.conc_std_max), x0 = (self.conc_std_min+self.conc_std_max)/2).x[0]
            self.conc_est_max = least_squares(self.conc_est_max_loss, bounds = (self.conc_std_min, self.conc_std_max), x0 = (self.conc_std_min+self.conc_std_max)/2).x[0]
        else:
            self.conc_est_max = least_squares(self.conc_est_min_loss, bounds = (self.conc_std_min, self.conc_std_max), x0 = (self.conc_std_min+self.conc_std_max)/2).x[0]
            self.conc_est_min = least_squares(self.conc_est_max_loss, bounds = (self.conc_std_min, self.conc_std_max), x0 = (self.conc_std_min+self.conc_std_max)/2).x[0]
            

        return
        
    # This function IS the standard curve. It takes a concentration argument, x,  and maps it to the corresponding most likely current value, y
    def predict(self, x):
        '''
        Function : linear

        Purpose  : Apply a linear function to the input argument, x, where linear(x) = p0 + p1*x

        Input: 
            - x (float/array) : an array of floats to be used as an argument for the linear model
            - p1 (float)      : the coefficient for the first order (linear) term of the linear model
            - p0 (float)      : the zeroth order (constant) term of the linear model

        Output:
            - y (float/array) : The mapping of the argument, x, under the linear model
        '''
        t1 = self.fit_params[1]*x

        y = self.fit_params[0] + t1 

        #y = self.linear_map(x, self.fit_params[0], self.fit_params[1])
        return y
    
    
    # This function is the error propagation formula or the parabolic standard curve It requires the covariance of the fit parameters and will map a cocnentration, x, to the variance of the current at that concentration:
    def errprop(self, x, cov):
        '''
        Purpose:
            To compute the error propagation of a line whose coefficients have been estimated with some uncertainty. The terms use the typical error propagation formula, where each term is a product of 
            partial derivatives multiplied by the corresponding covariance term. The method is often called the "Delta Method"

            The parabola function takes the form:   y = p0 + p1*X

        Input:
            x   (array/float)    : argument to the linear model
            cov (2D array/float) : the covariance matrix of the three input parameters p0, p1, p2

        Output:
            varprop (array/float) : propagated error evaluated at the input argument
        '''  
        import numpy as np
    
        # Diagonal terms of the error prop:
        term_1 = np.power(x, 2)*cov[0][0]
        term_2 = cov[1][1]

        # Mixed terms of the error prop:
        term_3 = 2*x*cov[0][1]

        varprop = term_1 + term_2 + term_3 
        return varprop
    
    
    
    
    # The next two functions compute the prediction band edges about the best fit line, which is defined by the predict function 
    # (user chooses confidence level via alpha parameter)
    def upper_pred_edge(self, x, alpha):
        tail_perc = alpha/2.
        return self.predict(x) + scipy.stats.t.ppf(1.-tail_perc, self.df)*np.sqrt(self.errprop(x, self.covar) + self.s**2)
    
    def lower_pred_edge(self, x, alpha):    
        tail_perc = alpha/2.
        return self.predict(x) - scipy.stats.t.ppf(1.-tail_perc, self.df)*np.sqrt(self.errprop(x, self.covar) + self.s**2)
    
    
    def conc_est_min_loss(self, x):
        return self.predict(x) - self.imin_est
        
    def conc_est_max_loss(self, x):
        return self.predict(x) - self.imax_est

        

