'''
Probability Distributions Module

Available distributions are:
    Weibull_Distribution
    Normal_Distribution
    Lognormal_Distribution
    Exponential_Distribution
    Gamma_Distribution
    Beta_Distribution

Methods:
    name - the name of the distribution. eg. 'Weibull'
    name2 - the name of the distribution with the number of parameters. eg. 'Weibull_2P'
    param_title_long - Useful in plot titles, legends and in printing strings. Varies by distribution. eg. 'Weibull Distribution (α=5,β=2)'
    param_title - Useful in plot titles, legends and in printing strings. Varies by distribution. eg. 'α=5,β=2'
    parameters - returns an array of parameters. eg. [alpha,beta,gamma]
    alpha, beta, gamma, Lambda, mu, sigma - these vary by distribution but will return the value of their respective parameter.
    mean
    variance
    standard_deviation
    skewness
    kurtosis
    excess_kurtosis
    median
    mode
    plot() - plots all functions (PDF,CDF,SF,HF,CHF)
    PDF() - plots the probability density function
    CDF() - plots the cumulative distribution function
    SF() - plots the survival function (also known as reliability function)
    HF() - plots the hazard function
    CHF() - plots the cumulative hazard function
    quantile() - Calculates the quantile (time until a fraction has failed) for a given fraction failing.
                 Also known as b life where b5 is the time at which 5% have failed.
    inverse_SF() - the inverse of the Survival Function. This is useful when producing QQ plots.
    mean_residual_life() - Average residual lifetime of an item given that the item has survived up to a given time.
                           Effectively the mean of the remaining amount (right side) of a distribution at a given time.
    stats() - prints all the descriptive statistics. Same as the statistics shown using .plot() but printed to console.
    random_samples() - draws random samples from the distribution to which it is applied. Same as rvs in scipy.stats.

Example usage:
dist = Weibull_Distribution(alpha = 8, beta = 1.2)
print(dist.mean)
    >> 7.525246866054174
print(dist.quantile(0.05))
    >> 0.6731943793488804
print(dist.mean_residual_life(15))
    >> 5.556500198354015
dist.plot()
    >> A figure of 5 plots and descriptive statistics will be displayed
dist.CHF()
    >> Cumulative Hazard Function plot will be displayed
values = dist.random_samples(number_of_samples=10000)
    >> random values will be generated from the distribution
'''

import scipy.stats as ss
import numpy as np
from scipy import integrate
import matplotlib.pyplot as plt
from reliability.Utils import round_to_decimals, transform_spaced
from scipy.optimize import fsolve, root
from autograd import jacobian as jac
import autograd.numpy as anp
import warnings

dec = 4  # number of decimals to use when rounding descriptive statistics and parameter titles
np.seterr(divide='ignore', invalid='ignore')  # ignore the divide by zero warnings


class Weibull_Distribution:
    '''
    Weibull probability distribution

    Creates a Distribution object.

    inputs:
    alpha - scale parameter
    beta - shape parameter
    gamma - threshold (offset) parameter. Default = 0

    methods:
    name - 'Weibull'
    name2 = 'Weibull_2P' or 'Weibull_3P' depending on the value of the gamma parameter
    param_title_long - Useful in plot titles, legends and in printing strings. eg. 'Weibull Distribution (α=5,β=2)'
    param_title - Useful in plot titles, legends and in printing strings. eg. 'α=5,β=2'
    parameters - [alpha,beta,gamma]
    alpha
    beta
    gamma
    mean
    variance
    standard_deviation
    skewness
    kurtosis
    excess_kurtosis
    median
    mode
    b5
    b95
    plot() - plots all functions (PDF,CDF,SF,HF,CHF)
    PDF() - plots the probability density function
    CDF() - plots the cumulative distribution function
    SF() - plots the survival function (also known as reliability function)
    HF() - plots the hazard function
    CHF() - plots the cumulative hazard function
    quantile() - Calculates the quantile (time until a fraction has failed) for a given fraction failing.
                 Also known as b life where b5 is the time at which 5% have failed.
    inverse_SF() - the inverse of the Survival Function. This is useful when producing QQ plots.
    mean_residual_life() - Average residual lifetime of an item given that the item has survived up to a given time.
                           Effectively the mean of the remaining amount (right side) of a distribution at a given time.
    stats() - prints all the descriptive statistics. Same as the statistics shown using .plot() but printed to console.
    random_samples() - draws random samples from the distribution to which it is applied. Same as rvs in scipy.stats.
    '''

    def __init__(self, alpha=None, beta=None, gamma=0, **kwargs):
        self.name = 'Weibull'
        self.alpha = float(alpha)
        self.beta = float(beta)
        if self.alpha is None or self.beta is None:
            raise ValueError('Parameters alpha and beta must be specified. Eg. Weibull_Distribution(alpha=5,beta=2)')
        self.gamma = float(gamma)
        self.parameters = np.array([self.alpha, self.beta, self.gamma])
        mean, var, skew, kurt = ss.weibull_min.stats(self.beta, scale=self.alpha, loc=self.gamma, moments='mvsk')
        self.mean = float(mean)
        self.variance = float(var)
        self.standard_deviation = var ** 0.5
        self.skewness = float(skew)
        self.kurtosis = kurt + 3
        self.excess_kurtosis = float(kurt)
        self.median = ss.weibull_min.median(self.beta, scale=self.alpha, loc=self.gamma)
        if self.beta >= 1:
            self.mode = self.alpha * ((self.beta - 1) / self.beta) ** (1 / self.beta) + self.gamma
        else:
            self.mode = r'No mode exists when $\beta$ < 1'
        if self.gamma != 0:
            self.param_title = str('α=' + str(round_to_decimals(self.alpha, dec)) + ',β=' + str(round_to_decimals(self.beta, dec)) + ',γ=' + str(round_to_decimals(self.gamma, dec)))
            self.param_title_long = str('Weibull Distribution (α=' + str(round_to_decimals(self.alpha, dec)) + ',β=' + str(round_to_decimals(self.beta, dec)) + ',γ=' + str(round_to_decimals(self.gamma, dec)) + ')')
            self.name2 = 'Weibull_3P'
        else:
            self.param_title = str('α=' + str(round_to_decimals(self.alpha, dec)) + ',β=' + str(round_to_decimals(self.beta, dec)))
            self.param_title_long = str('Weibull Distribution (α=' + str(round_to_decimals(self.alpha, dec)) + ',β=' + str(round_to_decimals(self.beta, dec)) + ')')
            self.name2 = 'Weibull_2P'
        self.b5 = ss.weibull_min.ppf(0.05, self.beta, scale=self.alpha, loc=self.gamma)
        self.b95 = ss.weibull_min.ppf(0.95, self.beta, scale=self.alpha, loc=self.gamma)

        # extracts values for confidence interval plotting
        if 'alpha_SE' in kwargs:
            self.alpha_SE = kwargs.pop('alpha_SE')
        else:
            self.alpha_SE = None
        if 'beta_SE' in kwargs:
            self.beta_SE = kwargs.pop('beta_SE')
        else:
            self.beta_SE = None
        if 'Cov_alpha_beta' in kwargs:
            self.Cov_alpha_beta = kwargs.pop('Cov_alpha_beta')
        else:
            self.Cov_alpha_beta = None
        if 'CI' in kwargs:
            CI = kwargs.pop('CI')
            self.Z = -ss.norm.ppf((1 - CI) / 2)
        else:
            self.Z = None
        if 'CI_type' in kwargs:
            self.CI_type = kwargs.pop('CI_type')
        else:
            self.CI_type = 'time'
        for item in kwargs.keys():
            print('WARNING:', item, 'not recognised as an appropriate entry in kwargs. Appropriate entries are alpha_SE, beta_SE, Cov_alpha_beta, CI, and CI_type')

    def plot(self, xvals=None, xmin=None, xmax=None):
        '''
        Plots all functions (PDF, CDF, SF, HF, CHF) and descriptive statistics in a single figure

        Inputs:
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *no plotting keywords are accepted

        Outputs:
        The plot will be shown. No need to use plt.show()
        '''

        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        pdf = ss.weibull_min.pdf(X, self.beta, scale=self.alpha, loc=self.gamma)
        cdf = ss.weibull_min.cdf(X, self.beta, scale=self.alpha, loc=self.gamma)
        sf = ss.weibull_min.sf(X, self.beta, scale=self.alpha, loc=self.gamma)
        hf = pdf / sf
        chf = -np.log(sf)

        plt.figure(figsize=(9, 7))
        text_title = str('Weibull Distribution' + '\n' + self.param_title)
        plt.suptitle(text_title, fontsize=15)
        plt.subplot(231)
        plt.plot(X, pdf)
        plt.title('Probability Density\nFunction')
        plt.subplot(232)
        plt.plot(X, cdf)
        plt.title('Cumulative Distribution\nFunction')
        plt.subplot(233)
        plt.plot(X, sf)
        plt.title('Survival Function')
        plt.subplot(234)
        plt.plot(X, hf)
        plt.title('Hazard Function')
        plt.subplot(235)
        plt.plot(X, chf)
        plt.title('Cumulative Hazard\nFunction')

        # descriptive statistics section
        plt.subplot(236)
        plt.axis('off')
        plt.ylim([0, 10])
        plt.xlim([0, 10])
        text_mean = str('Mean = ' + str(round_to_decimals(float(self.mean), dec)))
        text_median = str('Median = ' + str(round_to_decimals(self.median, dec)))
        try:
            text_mode = str('Mode = ' + str(round_to_decimals(self.mode, dec)))
        except:
            text_mode = str('Mode = ' + str(self.mode))  # required when mode is str
        text_b5 = str('$5^{th}$ quantile = ' + str(round_to_decimals(self.b5, dec)))
        text_b95 = str('$95^{th}$ quantile = ' + str(round_to_decimals(self.b95, dec)))
        text_std = str('Standard deviation = ' + str(round_to_decimals(self.variance ** 0.5, dec)))
        text_var = str('Variance = ' + str(round_to_decimals(float(self.variance), dec)))
        text_skew = str('Skewness = ' + str(round_to_decimals(float(self.skewness), dec)))
        text_ex_kurt = str('Excess kurtosis = ' + str(round_to_decimals(float(self.excess_kurtosis), dec)))
        plt.text(0, 9, text_mean)
        plt.text(0, 8, text_median)
        plt.text(0, 7, text_mode)
        plt.text(0, 6, text_b5)
        plt.text(0, 5, text_b95)
        plt.text(0, 4, text_std)
        plt.text(0, 3, text_var)
        plt.text(0, 2, text_skew)
        plt.text(0, 1, text_ex_kurt)
        plt.tight_layout()
        plt.subplots_adjust(hspace=0.3, top=0.84)
        plt.show()

    def PDF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the PDF (probability density function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        pdf = ss.weibull_min.pdf(X, self.beta, scale=self.alpha, loc=self.gamma)

        if show_plot == False:
            return pdf
        else:
            plt.plot(X, pdf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Probability density')
            text_title = str('Weibull Distribution\n' + ' Probability Density Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return pdf

    def CDF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the CDF (cumulative distribution function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''

        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        ####this determines if the user has specified for the CI bounds to be shown or hidden.
        kwargs_list = kwargs.keys()
        if 'plot_CI' in kwargs_list:
            plot_CI = kwargs.pop('plot_CI')
        elif 'show_CI' in kwargs_list:
            plot_CI = kwargs.pop('show_CI')
        else:
            plot_CI = True  # default
        if plot_CI not in [True, False]:
            print('WARNING: unexpected value in kwargs. To show/hide the CI you can specify either show_CI=True/False or plot_CI=True/False')
            plot_CI = True

        cdf = ss.weibull_min.cdf(X, self.beta, scale=self.alpha, loc=self.gamma)

        if show_plot == False:
            return cdf
        else:
            xrange = plt.xlim()  # this ensures the previously plotted objects are considered when setting the range
            p = plt.plot(X, cdf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Fraction failing')
            text_title = str('Weibull Distribution\n' + ' Cumulative Distribution Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)

            Weibull_Distribution.__weibull_CI(self, func='CDF', plot_CI=plot_CI, text_title=text_title, color=p[0].get_color())

            return cdf

    def SF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the SF (survival function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        ####this determines if the user has specified for the CI bounds to be shown or hidden. Applicable kwargs are show_CI or plot_CI
        kwargs_list = kwargs.keys()
        if 'plot_CI' in kwargs_list:
            plot_CI = kwargs.pop('plot_CI')
        elif 'show_CI' in kwargs_list:
            plot_CI = kwargs.pop('show_CI')
        else:
            plot_CI = True  # default
        if plot_CI not in [True, False]:
            print('WARNING: unexpected value in kwargs. To show/hide the CI you can specify either show_CI=True/False or plot_CI=True/False')
            plot_CI = True

        sf = ss.weibull_min.sf(X, self.beta, scale=self.alpha, loc=self.gamma)

        if show_plot == False:
            return sf
        else:
            xrange = plt.xlim()  # this ensures the previously plotted objects are considered when setting the range
            p = plt.plot(X, sf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Fraction surviving')
            text_title = str('Weibull Distribution\n' + ' Survival Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)

            Weibull_Distribution.__weibull_CI(self, func='SF', plot_CI=plot_CI, text_title=text_title, color=p[0].get_color())

            return sf

    def HF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the HF (hazard function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''

        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        # this determines if the user has specified for the CI bounds to be shown or hidden. Applicable kwargs are show_CI or plot_CI
        kwargs_list = kwargs.keys()
        if 'plot_CI' in kwargs_list:
            plot_CI = kwargs.pop('plot_CI')
        elif 'show_CI' in kwargs_list:
            plot_CI = kwargs.pop('show_CI')
        else:
            plot_CI = True  # default
        if plot_CI not in [True, False]:
            print('WARNING: unexpected value in kwargs. To show/hide the CI you can specify either show_CI=True/False or plot_CI=True/False')
            plot_CI = True

        hf = ss.weibull_min.pdf(X, self.beta, scale=self.alpha, loc=self.gamma) / ss.weibull_min.sf(X, self.beta, scale=self.alpha, loc=self.gamma)
        self._hf = hf  # required by the CI plotting part

        if show_plot == False:
            return hf
        else:
            p = plt.plot(X, hf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Hazard')
            text_title = str('Weibull Distribution\n' + ' Hazard Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)

            Weibull_Distribution.__weibull_CI(self, func='HF', plot_CI=plot_CI, text_title=text_title, color=p[0].get_color())

            return hf

    def CHF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the CHF (cumulative hazard function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        # this determines if the user has specified for the CI bounds to be shown or hidden. Applicable kwargs are show_CI or plot_CI
        kwargs_list = kwargs.keys()
        if 'plot_CI' in kwargs_list:
            plot_CI = kwargs.pop('plot_CI')
        elif 'show_CI' in kwargs_list:
            plot_CI = kwargs.pop('show_CI')
        else:
            plot_CI = True  # default
        if plot_CI not in [True, False]:
            print('WARNING: unexpected value in kwargs. To show/hide the CI you can specify either show_CI=True/False or plot_CI=True/False')
            plot_CI = True

        chf = -np.log(ss.weibull_min.sf(X, self.beta, scale=self.alpha, loc=self.gamma))
        self._chf = chf  # required by the CI plotting part

        if show_plot == False:
            return chf
        else:
            p = plt.plot(X, chf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Cumulative hazard')
            text_title = str('Weibull Distribution\n' + ' Cumulative Hazard Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)

            Weibull_Distribution.__weibull_CI(self, func='CHF', plot_CI=plot_CI, text_title=text_title, color=p[0].get_color())

            return chf

    def __weibull_CI(self, func, plot_CI, text_title, color):
        '''
        Generates the confidence intervals for CDF, SF, HF, CHF
        This is a hidden function intended only for use by other functions.
        '''
        # determine the xrange so it can be reimposed after plotting the CI. Without this the xrange gets too large as some CI can extend a long way.
        x_lims = plt.xlim()
        y_lims = plt.ylim()
        if self.alpha_SE is not None and self.beta_SE is not None and self.Cov_alpha_beta is not None and self.Z is not None and plot_CI is True:
            if self.CI_type in ['time', 't', 'T', 'TIME', 'Time']:
                self.CI_type = 'time'
            elif self.CI_type in ['reliability', 'r', 'R', 'RELIABILITY', 'rel', 'REL', 'Reliability']:
                self.CI_type = 'reliability'
            CI_100 = round((1 - ss.norm.cdf(-self.Z) * 2) * 100, 4)  # Converts Z to CI and formats the confidence interval value ==> 0.95 becomes 95
            if CI_100 % 1 == 0:
                CI_100 = int(CI_100)  # removes decimals if the only decimal is 0
            text_title = str(text_title + '\n' + str(CI_100) + '% confidence bounds on ' + self.CI_type)  # Adds the CI and CI_type to the title
            plt.title(text_title)
            plt.subplots_adjust(top=0.83)

            # functions for upper and lower confidence bounds on time and reliability
            if func in ['CDF', 'SF', 'CHF']:
                def uR(t, alpha, beta):  # u = ln(-ln(R))
                    return beta * (anp.log(t) - anp.log(alpha))

                du_da_R = jac(uR, 1)  # derivative wrt alpha (for bounds on reliability)
                du_db_R = jac(uR, 2)  # derivative wrt beta (for bounds on reliability)

                def uT(R, alpha, beta):  # u = ln(t)
                    return (1 / beta) * anp.log(-anp.log(R)) + anp.log(alpha)

                du_da_T = jac(uT, 1)  # derivative wrt alpha (for bounds on time)
                du_db_T = jac(uT, 2)  # derivative wrt beta (for bounds on time)

                def var_uR(self, t):
                    return du_da_R(t, self.alpha, self.beta) ** 2 * self.alpha_SE ** 2 \
                           + du_db_R(t, self.alpha, self.beta) ** 2 * self.beta_SE ** 2 \
                           + 2 * du_da_R(t, self.alpha, self.beta) * du_db_R(t, self.alpha, self.beta) * self.Cov_alpha_beta

                def var_uT(self, R):
                    return du_da_T(R, self.alpha, self.beta) ** 2 * self.alpha_SE ** 2 \
                           + du_db_T(R, self.alpha, self.beta) ** 2 * self.beta_SE ** 2 \
                           + 2 * du_da_T(R, self.alpha, self.beta) * du_db_T(R, self.alpha, self.beta) * self.Cov_alpha_beta

            elif func == 'HF':
                def uh(t, alpha, beta):  # u = ln(h)
                    return anp.log((beta / alpha) * ((t / alpha) ** (beta - 1)))

                du_da_h = jac(uh, 1)  # derivative wrt alpha (for bounds on reliability)
                du_db_h = jac(uh, 2)  # derivative wrt beta (for bounds on reliability)

                def uT(h, alpha, beta):  # u = ln(t)
                    return anp.log(h * alpha / beta) / (beta - 1) + anp.log(alpha)

                du_da_T = jac(uT, 1)  # derivative wrt alpha (for bounds on time)
                du_db_T = jac(uT, 2)  # derivative wrt beta (for bounds on time)

                def var_uh(self, t):
                    return du_da_h(t, self.alpha, self.beta) ** 2 * self.alpha_SE ** 2 \
                           + du_db_h(t, self.alpha, self.beta) ** 2 * self.beta_SE ** 2 \
                           + 2 * du_da_h(t, self.alpha, self.beta) * du_db_h(t, self.alpha, self.beta) * self.Cov_alpha_beta

                def var_uT(self, h):
                    return du_da_T(h, self.alpha, self.beta) ** 2 * self.alpha_SE ** 2 \
                           + du_db_T(h, self.alpha, self.beta) ** 2 * self.beta_SE ** 2 \
                           + 2 * du_da_T(h, self.alpha, self.beta) * du_db_T(h, self.alpha, self.beta) * self.Cov_alpha_beta
            else:
                raise ValueError('func must be either CDF, SF, HF, or CHF')

            if self.CI_type == 'time':  # Confidence bounds on time (in terms of reliability)
                if func == 'CHF':  # CHF and CDF probability plot
                    chf_array = np.logspace(-5, np.log10(self._chf[-1]), 100)
                    R = np.exp(-chf_array)
                elif func == 'HF':
                    h = np.logspace(-5, np.log10(self._hf[-1]), 100)  # NEED TO GET AN ACTUAL MAX VALUE HERE BASED OFF THE PARAMS
                else:
                    R = transform_spaced('weibull', y_lower=1e-8, y_upper=1 - 1e-8, num=100)

                if func in ['CDF', 'SF', 'CHF']:
                    u_T_lower = uT(R, self.alpha, self.beta) - self.Z * (var_uT(self, R) ** 0.5)
                    T_lower0 = np.exp(u_T_lower) + self.gamma
                    u_T_upper = uT(R, self.alpha, self.beta) + self.Z * (var_uT(self, R) ** 0.5)
                    T_upper0 = np.exp(u_T_upper) + self.gamma
                else:  # HF
                    u_T_lower = uT(h, self.alpha, self.beta) - self.Z * (var_uT(self, h) ** 0.5)
                    T_lower0 = np.exp(u_T_lower) + self.gamma
                    u_T_upper = uT(h, self.alpha, self.beta) + self.Z * (var_uT(self, h) ** 0.5)
                    T_upper0 = np.exp(u_T_upper) + self.gamma

                min_time, max_time = 1e-12, 1e12  # these are the plotting limits for the CIs
                T_lower = np.where(T_lower0 < min_time, min_time, T_lower0)  # this applies a limit along time (x-axis) so that fill_betweenx is not plotting to infinity
                T_upper = np.where(T_upper0 > max_time, max_time, T_upper0)

                if func == 'CDF':
                    yy = 1 - R
                elif func == 'SF':
                    yy = R
                elif func == 'HF':
                    yy = h
                elif func == 'CHF':
                    yy = -np.log(R)

                plt.fill_betweenx(yy, T_lower, T_upper, color=color, alpha=0.3, linewidth=0)
                p_lower = plt.plot(T_lower, yy, linewidth=0, color=color)
                p_upper = plt.plot(T_upper, yy, linewidth=0, color=color)
                # p_lower = plt.scatter(T_lower, yy, linewidth=1, color='blue')
                # p_upper = plt.scatter(T_upper, yy, linewidth=1, color='red')

            elif self.CI_type == 'reliability':  # Confidence bounds on Reliability (in terms of time)
                if plt.gca().get_xscale() != 'linear':  # just for probability plot
                    t_max = ss.weibull_min.ppf(0.99999, self.beta, scale=self.alpha) * 1e4
                    t = np.geomspace(1e-5, t_max, 100)
                else:
                    t_max = self.b95 * 5
                    t = np.linspace(1e-5, t_max, 100)

                if func in ['CDF', 'SF', 'CHF']:
                    u_R_lower = uR(t, self.alpha, self.beta) + self.Z * var_uR(self, t) ** 0.5  # note that gamma is incorporated into uR but not in var_uR. This is the same as just shifting a Weibull_2P across
                    R_lower0 = np.exp(-np.exp(u_R_lower))
                    u_R_upper = uR(t, self.alpha, self.beta) - self.Z * var_uR(self, t) ** 0.5
                    R_upper0 = np.exp(-np.exp(u_R_upper))
                    if func == 'CHF':
                        min_R, max_R = np.exp(-self._chf[-1] * 10), np.exp(-self._chf[0])
                    else:  # CDF, SF
                        min_R, max_R = 1e-12, 1 - 1e-12
                    R_lower = np.where(R_lower0 < min_R, min_R, R_lower0)  # this applies a limit along Reliability (y-axis) so that fill_between is not plotting to infinity
                    R_upper = np.where(R_upper0 > max_R, max_R, np.where(R_upper0 < min_R, min_R, R_upper0))
                else:  # HF
                    u_h_lower = uh(t, self.alpha, self.beta) - self.Z * var_uh(self, t) ** 0.5  # note that gamma is incorporated into uh but not in var_uh. This is the same as just shifting a Weibull_2P across
                    h_lower0 = np.exp(u_h_lower)
                    u_h_upper = uh(t, self.alpha, self.beta) + self.Z * var_uh(self, t) ** 0.5
                    h_upper0 = np.exp(u_h_upper)
                    max_h = self._hf[-1] * 10
                    h_lower = np.where(h_lower0 > max_h, max_h, h_lower0)  # this applies a limit along Hazard (y-axis) so that fill_between is not plotting to infinity
                    h_upper = np.where(h_upper0 > max_h, max_h, h_upper0)

                if func == 'CDF':
                    yy_lower = 1 - R_lower
                    yy_upper = 1 - R_upper
                elif func == 'SF':
                    yy_lower = R_lower
                    yy_upper = R_upper
                elif func == 'HF':
                    yy_lower = h_lower
                    yy_upper = h_upper
                elif func == 'CHF':
                    yy_lower = -np.log(R_lower)
                    yy_upper = -np.log(R_upper)

                plt.fill_between(t + self.gamma, yy_lower, yy_upper, color=color, alpha=0.3, linewidth=0)
                p_lower = plt.plot(t + self.gamma, yy_lower, linewidth=0, color=color)
                p_upper = plt.plot(t + self.gamma, yy_upper, linewidth=0, color=color)  # still need to specify color otherwise the invisible CI lines will consume default colors
                # p_upper = plt.scatter(t + self.gamma, yy_upper, color='red')
                # p_lower = plt.scatter(t + self.gamma, yy_lower, color='blue')

            # reimpose the xlim and ylim to be what it was before the CI were plotted
            plt.xlim(x_lims)
            plt.ylim(y_lims)

    def quantile(self, q):
        '''
        Quantile calculator

        :param q: quantile to be calculated
        :return: the probability (area under the curve) that a random variable from the distribution is < q
        '''
        if type(q) == int or type(q) == float:
            if q < 0 or q > 1:
                raise ValueError('Quantile must be between 0 and 1')
        elif type(q) == np.ndarray or type(q) == list:
            if min(q) < 0 or max(q) > 1:
                raise ValueError('Quantile must be between 0 and 1')
        else:
            raise ValueError('Quantile must be of type int, float, list, array')
        return ss.weibull_min.ppf(q, self.beta, scale=self.alpha, loc=self.gamma)

    def inverse_SF(self, q):
        '''
        Inverse Survival function calculator

        :param q: quantile to be calculated
        :return: the inverse of the survival function at q
        '''
        if type(q) == int or type(q) == float:
            if q < 0 or q > 1:
                raise ValueError('Quantile must be between 0 and 1')
        elif type(q) == np.ndarray or type(q) == list:
            if min(q) < 0 or max(q) > 1:
                raise ValueError('Quantile must be between 0 and 1')
        else:
            raise ValueError('Quantile must be of type int, float, list, array')
        return ss.weibull_min.isf(q, self.beta, scale=self.alpha, loc=self.gamma)

    def mean_residual_life(self, t):
        '''
        Mean Residual Life calculator

        :param t: time at which MRL is to be evaluated
        :return: MRL
        '''
        R = lambda x: ss.weibull_min.sf(x, self.beta, scale=self.alpha, loc=self.gamma)
        integral_R, error = integrate.quad(R, t, np.inf)
        MRL = integral_R / R(t)
        return MRL

    def stats(self):
        if self.gamma == 0:
            print('Descriptive statistics for Weibull distribution with alpha =', self.alpha, 'and beta =', self.beta)
        else:
            print('Descriptive statistics for Weibull distribution with alpha =', self.alpha, ', beta =', self.beta, ', and gamma =', self.gamma)
        print('Mean = ', self.mean)
        print('Median =', self.median)
        print('Mode =', self.mode)
        print('5th quantile =', self.b5)
        print('95th quantile =', self.b95)
        print('Standard deviation =', self.standard_deviation)
        print('Variance =', self.variance)
        print('Skewness =', self.skewness)
        print('Excess kurtosis =', self.excess_kurtosis)

    def random_samples(self, number_of_samples, seed=None):
        '''
        random_samples
        Draws random samples from the probability distribution

        :param number_of_samples: the number of samples to be drawn
        :param seed: the random seed. Default is None
        :return: the random samples
        '''
        if type(number_of_samples) != int or number_of_samples < 1:
            raise ValueError('number_of_samples must be an integer greater than 1')
        if seed is not None:
            np.random.seed(seed)
        RVS = ss.weibull_min.rvs(self.beta, scale=self.alpha, loc=self.gamma, size=number_of_samples)
        return RVS


class Normal_Distribution:
    '''
    Normal probability distribution

    Creates a Distribution object.

    inputs:
    mu - location parameter (mean)
    sigma - scale parameter (standard deviation)

    methods:
    name - 'Normal'
    name2 = 'Normal_2P'
    param_title_long - Useful in plot titles, legends and in printing strings. eg. 'Normal Distribution (μ=5,σ=2)'
    param_title - Useful in plot titles, legends and in printing strings. eg. 'μ=5,σ=2'
    parameters - [mu,sigma]
    mu
    sigma
    mean
    variance
    standard_deviation
    skewness
    kurtosis
    excess_kurtosis
    median
    mode
    b5
    b95
    plot() - plots all functions (PDF,CDF,SF,HF,CHF)
    PDF() - plots the probability density function
    CDF() - plots the cumulative distribution function
    SF() - plots the survival function (also known as reliability function)
    HF() - plots the hazard function
    CHF() - plots the cumulative hazard function
    quantile() - Calculates the quantile (time until a fraction has failed) for a given fraction failing.
                 Also known as b life where b5 is the time at which 5% have failed.
    inverse_SF() - the inverse of the Survival Function. This is useful when producing QQ plots.
    mean_residual_life() - Average residual lifetime of an item given that the item has survived up to a given time.
                           Effectively the mean of the remaining amount (right side) of a distribution at a given time.
    stats() - prints all the descriptive statistics. Same as the statistics shown using .plot() but printed to console.
    random_samples() - draws random samples from the distribution to which it is applied. Same as rvs in scipy.stats.
    '''

    def __init__(self, mu=None, sigma=None):
        self.name = 'Normal'
        self.name2 = 'Normal_2P'
        self.mu = mu
        self.sigma = sigma
        if self.mu is None or self.sigma is None:
            raise ValueError('Parameters mu and sigma must be specified. Eg. Normal_Distribution(mu=5,sigma=2)')
        self.parameters = np.array([self.mu, self.sigma])
        self.mean = mu
        self.variance = sigma ** 2
        self.standard_deviation = sigma
        self.skewness = 0
        self.kurtosis = 3
        self.excess_kurtosis = 0
        self.median = mu
        self.mode = mu
        self.param_title = str('μ=' + str(round_to_decimals(self.mu, dec)) + ',σ=' + str(round_to_decimals(self.sigma, dec)))
        self.param_title_long = str('Normal Distribution (μ=' + str(round_to_decimals(self.mu, dec)) + ',σ=' + str(round_to_decimals(self.sigma, dec)) + ')')
        self.b5 = ss.norm.ppf(0.05, loc=self.mu, scale=self.sigma)
        self.b95 = ss.norm.ppf(0.95, loc=self.mu, scale=self.sigma)

    def plot(self, xvals=None, xmin=None, xmax=None):
        '''
        Plots all functions (PDF, CDF, SF, HF, CHF) and descriptive statistics in a single figure

        Inputs:
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *no plotting keywords are accepted

        Outputs:
        The plot will be shown. No need to use plt.show()
        '''
        if xvals is not None:
            if type(xvals) is list:
                X = np.array(xvals)
            elif type(xvals) in [np.ndarray, float, int]:
                X = xvals
            else:
                raise ValueError('Unexpected type given in xvals. xvals must be array, list, int, or float')
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(self.mu - 3 * self.sigma, self.mu + 3 * self.sigma, 1000)  # if no limits are specified, they are assumed

        pdf = ss.norm.pdf(X, self.mu, self.sigma)
        cdf = ss.norm.cdf(X, self.mu, self.sigma)
        sf = ss.norm.sf(X, self.mu, self.sigma)
        hf = pdf / sf
        chf = -np.log(sf)

        plt.figure(figsize=(9, 7))
        text_title = str('Normal Distribution' + '\n' + self.param_title)
        plt.suptitle(text_title, fontsize=15)
        plt.subplot(231)
        plt.plot(X, pdf)
        plt.title('Probability Density\nFunction')
        plt.subplot(232)
        plt.plot(X, cdf)
        plt.title('Cumulative Distribution\nFunction')
        plt.subplot(233)
        plt.plot(X, sf)
        plt.title('Survival Function')
        plt.subplot(234)
        plt.plot(X, hf)
        plt.title('Hazard Function')
        plt.subplot(235)
        plt.plot(X, chf)
        plt.title('Cumulative Hazard\nFunction')

        # descriptive statistics section
        plt.subplot(236)
        plt.axis('off')
        plt.ylim([0, 10])
        plt.xlim([0, 10])
        text_mean = str('Mean = ' + str(round_to_decimals(float(self.mean), dec)))
        text_median = str('Median = ' + str(round_to_decimals(self.median, dec)))
        try:
            text_mode = str('Mode = ' + str(round_to_decimals(self.mode, dec)))
        except:
            text_mode = str('Mode = ' + str(self.mode))  # required when mode is str
        text_b5 = str('$5^{th}$ quantile = ' + str(round_to_decimals(self.b5, dec)))
        text_b95 = str('$95^{th}$ quantile = ' + str(round_to_decimals(self.b95, dec)))
        text_std = str('Standard deviation = ' + str(round_to_decimals(self.variance ** 0.5, dec)))
        text_var = str('Variance = ' + str(round_to_decimals(float(self.variance), dec)))
        text_skew = str('Skewness = ' + str(round_to_decimals(float(self.skewness), dec)))
        text_ex_kurt = str('Excess kurtosis = ' + str(round_to_decimals(float(self.excess_kurtosis), dec)))
        plt.text(0, 9, text_mean)
        plt.text(0, 8, text_median)
        plt.text(0, 7, text_mode)
        plt.text(0, 6, text_b5)
        plt.text(0, 5, text_b95)
        plt.text(0, 4, text_std)
        plt.text(0, 3, text_var)
        plt.text(0, 2, text_skew)
        plt.text(0, 1, text_ex_kurt)
        plt.tight_layout()
        plt.subplots_adjust(hspace=0.3, top=0.84)
        plt.show()

    def PDF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the PDF (probability density function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            if type(xvals) is list:
                X = np.array(xvals)
            elif type(xvals) in [np.ndarray, float, int]:
                X = xvals
            else:
                raise ValueError('Unexpected type given in xvals. xvals must be array, list, int, or float')
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(self.mu - 3 * self.sigma, self.mu + 3 * self.sigma, 1000)  # if no limits are specified, they are assumed

        pdf = ss.norm.pdf(X, self.mu, self.sigma)

        if show_plot == False:
            return pdf
        else:
            plt.plot(X, pdf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Probability density')
            text_title = str('Normal Distribution\n' + ' Probability Density Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return pdf

    def CDF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the CDF (cumulative distribution function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            if type(xvals) is list:
                X = np.array(xvals)
            elif type(xvals) in [np.ndarray, float, int, np.float64]:
                X = xvals
            else:
                raise ValueError('Unexpected type given in xvals. xvals must be array, list, int, or float')
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(self.mu - 3 * self.sigma, self.mu + 3 * self.sigma, 1000)  # if no limits are specified, they are assumed

        cdf = ss.norm.cdf(X, self.mu, self.sigma)

        if show_plot == False:
            return cdf
        else:
            plt.plot(X, cdf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Fraction failing')
            text_title = str('Normal Distribution\n' + ' Cumulative Distribution Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return cdf

    def SF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the SF (survival function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            if type(xvals) is list:
                X = np.array(xvals)
            elif type(xvals) in [np.ndarray, float, int]:
                X = xvals
            else:
                raise ValueError('Unexpected type given in xvals. xvals must be array, list, int, or float')
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(self.mu - 3 * self.sigma, self.mu + 3 * self.sigma, 1000)  # if no limits are specified, they are assumed

        sf = ss.norm.sf(X, self.mu, self.sigma)

        if show_plot == False:
            return sf
        else:
            plt.plot(X, sf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Fraction surviving')
            text_title = str('Normal Distribution\n' + ' Survival Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return sf

    def HF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the HF (hazard function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            if type(xvals) is list:
                X = np.array(xvals)
            elif type(xvals) in [np.ndarray, float, int]:
                X = xvals
            else:
                raise ValueError('Unexpected type given in xvals. xvals must be array, list, int, or float')
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(self.mu - 3 * self.sigma, self.mu + 3 * self.sigma, 1000)  # if no limits are specified, they are assumed

        hf = ss.norm.pdf(X, self.mu, self.sigma) / ss.norm.sf(X, self.mu, self.sigma)

        if show_plot == False:
            return hf
        else:
            plt.plot(X, hf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Hazard')
            text_title = str('Normal Distribution\n' + ' Hazard Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return hf

    def CHF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the CHF (cumulative hazard function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            if type(xvals) is list:
                X = np.array(xvals)
            elif type(xvals) in [np.ndarray, float, int]:
                X = xvals
            else:
                raise ValueError('Unexpected type given in xvals. xvals must be array, list, int, or float')
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(self.mu - 3 * self.sigma, self.mu + 3 * self.sigma, 1000)  # if no limits are specified, they are assumed

        chf = -np.log(ss.norm.sf(X, self.mu, self.sigma))

        if show_plot == False:
            return chf
        else:
            plt.plot(X, chf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Cumulative hazard')
            text_title = str('Normal Distribution\n' + ' Cumulative Hazard Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return chf

    def quantile(self, q):
        '''
        Quantile calculator

        :param q: quantile to be calculated
        :return: the probability (area under the curve) that a random variable from the distribution is < q
        '''
        if type(q) == int or type(q) == float:
            if q < 0 or q > 1:
                raise ValueError('Quantile must be between 0 and 1')
        elif type(q) == np.ndarray or type(q) == list:
            if min(q) < 0 or max(q) > 1:
                raise ValueError('Quantile must be between 0 and 1')
        else:
            raise ValueError('Quantile must be of type int, float, list, array')
        return ss.norm.ppf(q, loc=self.mu, scale=self.sigma)

    def inverse_SF(self, q):
        '''
        Inverse Survival function calculator

        :param q: quantile to be calculated
        :return: the inverse of the survival function at q
        '''
        if type(q) == int or type(q) == float:
            if q < 0 or q > 1:
                raise ValueError('Quantile must be between 0 and 1')
        elif type(q) == np.ndarray or type(q) == list:
            if min(q) < 0 or max(q) > 1:
                raise ValueError('Quantile must be between 0 and 1')
        else:
            raise ValueError('Quantile must be of type int, float, list, array')
        return ss.norm.isf(q, loc=self.mu, scale=self.sigma)

    def mean_residual_life(self, t):
        '''
        Mean Residual Life calculator

        :param t: time at which MRL is to be evaluated
        :return: MRL
        '''
        R = lambda x: ss.norm.sf(x, loc=self.mu, scale=self.sigma)
        integral_R, error = integrate.quad(R, t, np.inf)
        MRL = integral_R / R(t)
        return MRL

    def stats(self):
        print('Descriptive statistics for Normal distribution with mu =', self.mu, 'and sigma =', self.sigma)
        print('Mean = ', self.mean)
        print('Median =', self.median)
        print('Mode =', self.mode)
        print('5th quantile =', self.b5)
        print('95th quantile =', self.b95)
        print('Standard deviation =', self.standard_deviation)
        print('Variance =', self.variance)
        print('Skewness =', self.skewness)
        print('Excess kurtosis =', self.excess_kurtosis)

    def random_samples(self, number_of_samples, seed=None):
        '''
        random_samples
        Draws random samples from the probability distribution

        :param number_of_samples: the number of samples to be drawn
        :param seed: the random seed. Default is None
        :return: the random samples
        '''
        if type(number_of_samples) != int or number_of_samples < 1:
            raise ValueError('number_of_samples must be an integer greater than 1')
        if seed is not None:
            np.random.seed(seed)
        RVS = ss.norm.rvs(loc=self.mu, scale=self.sigma, size=number_of_samples)
        return RVS


class Lognormal_Distribution:
    '''
    Lognormal probability distribution

    Creates a Distribution object.

    inputs:
    mu - location parameter
    sigma - scale parameter
    gamma - threshold (offset) parameter. Default = 0

    methods:
    name - 'Lognormal'
    name2 = 'Lognormal_2P' or 'Lognormal_3P' depending on the value of the gamma parameter
    param_title_long - Useful in plot titles, legends and in printing strings. eg. 'Lognormal Distribution (μ=5,σ=2)'
    param_title - Useful in plot titles, legends and in printing strings. eg. 'μ=5,σ=2'
    parameters - [mu,sigma,gamma]
    mu
    sigma
    gamma
    mean
    variance
    standard_deviation
    skewness
    kurtosis
    excess_kurtosis
    median
    mode
    b5
    b95
    plot() - plots all functions (PDF,CDF,SF,HF,CHF)
    PDF() - plots the probability density function
    CDF() - plots the cumulative distribution function
    SF() - plots the survival function (also known as reliability function)
    HF() - plots the hazard function
    CHF() - plots the cumulative hazard function
    quantile() - Calculates the quantile (time until a fraction has failed) for a given fraction failing.
                 Also known as b life where b5 is the time at which 5% have failed.
    inverse_SF() - the inverse of the Survival Function. This is useful when producing QQ plots.
    mean_residual_life() - Average residual lifetime of an item given that the item has survived up to a given time.
                           Effectively the mean of the remaining amount (right side) of a distribution at a given time.
    stats() - prints all the descriptive statistics. Same as the statistics shown using .plot() but printed to console.
    random_samples() - draws random samples from the distribution to which it is applied. Same as rvs in scipy.stats.
    '''

    def __init__(self, mu=None, sigma=None, gamma=0):
        self.name = 'Lognormal'
        self.mu = mu
        self.sigma = sigma
        if self.mu is None or self.sigma is None:
            raise ValueError('Parameters mu and sigma must be specified. Eg. Lognormal_Distribution(mu=5,sigma=2)')
        self.gamma = gamma
        self.parameters = np.array([self.mu, self.sigma, self.gamma])
        mean, var, skew, kurt = ss.lognorm.stats(self.sigma, self.gamma, np.exp(self.mu), moments='mvsk')
        self.mean = float(mean)
        self.variance = float(var)
        self.standard_deviation = var ** 0.5
        self.skewness = float(skew)
        self.kurtosis = kurt + 3
        self.excess_kurtosis = float(kurt)
        self.median = ss.lognorm.median(self.sigma, self.gamma, np.exp(self.mu))
        self.mode = np.exp(self.mu - self.sigma ** 2) + self.gamma
        if self.gamma != 0:
            self.param_title = str('μ=' + str(round_to_decimals(self.mu, dec)) + ',σ=' + str(round_to_decimals(self.sigma, dec)) + ',γ=' + str(round_to_decimals(self.gamma, dec)))
            self.param_title_long = str('Lognormal Distribution (μ=' + str(round_to_decimals(self.mu, dec)) + ',σ=' + str(round_to_decimals(self.sigma, dec)) + ',γ=' + str(round_to_decimals(self.gamma, dec)) + ')')
            self.name2 = 'Lognormal_3P'
        else:
            self.param_title = str('μ=' + str(round_to_decimals(self.mu, dec)) + ',σ=' + str(round_to_decimals(self.sigma, dec)))
            self.param_title_long = str('Lognormal Distribution (μ=' + str(round_to_decimals(self.mu, dec)) + ',σ=' + str(round_to_decimals(self.sigma, dec)) + ')')
            self.name2 = 'Lognormal_2P'
        self.b5 = ss.lognorm.ppf(0.05, self.sigma, self.gamma, np.exp(self.mu))  # note that scipy uses mu in a log way compared to most other software, so we must take the exp of the input
        self.b95 = ss.lognorm.ppf(0.95, self.sigma, self.gamma, np.exp(self.mu))

    def plot(self, xvals=None, xmin=None, xmax=None):
        '''
        Plots all functions (PDF, CDF, SF, HF, CHF) and descriptive statistics in a single figure

        Inputs:
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *no plotting keywords are accepted

        Outputs:
        The plot will be shown. No need to use plt.show()
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        pdf = ss.lognorm.pdf(X, self.sigma, self.gamma, np.exp(self.mu))
        cdf = ss.lognorm.cdf(X, self.sigma, self.gamma, np.exp(self.mu))
        sf = ss.lognorm.sf(X, self.sigma, self.gamma, np.exp(self.mu))
        hf = pdf / sf
        chf = -np.log(sf)

        plt.figure(figsize=(9, 7))
        text_title = str('Lognormal Distribution' + '\n' + self.param_title)
        plt.suptitle(text_title, fontsize=15)
        plt.subplot(231)
        plt.plot(X, pdf)
        plt.title('Probability Density\nFunction')
        plt.subplot(232)
        plt.plot(X, cdf)
        plt.title('Cumulative Distribution\nFunction')
        plt.subplot(233)
        plt.plot(X, sf)
        plt.title('Survival Function')
        plt.subplot(234)
        plt.plot(X, hf)
        plt.title('Hazard Function')
        plt.subplot(235)
        plt.plot(X, chf)
        plt.title('Cumulative Hazard\nFunction')

        # descriptive statistics section
        plt.subplot(236)
        plt.axis('off')
        plt.ylim([0, 10])
        plt.xlim([0, 10])
        text_mean = str('Mean = ' + str(round_to_decimals(float(self.mean), dec)))
        text_median = str('Median = ' + str(round_to_decimals(self.median, dec)))
        try:
            text_mode = str('Mode = ' + str(round_to_decimals(self.mode, dec)))
        except:
            text_mode = str('Mode = ' + str(self.mode))  # required when mode is str
        text_b5 = str('$5^{th}$ quantile = ' + str(round_to_decimals(self.b5, dec)))
        text_b95 = str('$95^{th}$ quantile = ' + str(round_to_decimals(self.b95, dec)))
        text_std = str('Standard deviation = ' + str(round_to_decimals(self.variance ** 0.5, dec)))
        text_var = str('Variance = ' + str(round_to_decimals(float(self.variance), dec)))
        text_skew = str('Skewness = ' + str(round_to_decimals(float(self.skewness), dec)))
        text_ex_kurt = str('Excess kurtosis = ' + str(round_to_decimals(float(self.excess_kurtosis), dec)))
        plt.text(0, 9, text_mean)
        plt.text(0, 8, text_median)
        plt.text(0, 7, text_mode)
        plt.text(0, 6, text_b5)
        plt.text(0, 5, text_b95)
        plt.text(0, 4, text_std)
        plt.text(0, 3, text_var)
        plt.text(0, 2, text_skew)
        plt.text(0, 1, text_ex_kurt)
        plt.tight_layout()
        plt.subplots_adjust(hspace=0.3, top=0.84)
        plt.show()

    def PDF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the PDF (probability density function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        pdf = ss.lognorm.pdf(X, self.sigma, self.gamma, np.exp(self.mu))

        if show_plot == False:
            return pdf
        else:
            plt.plot(X, pdf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Probability density')
            text_title = str('Lognormal Distribution\n' + ' Probability Density Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return pdf

    def CDF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the CDF (cumulative distribution function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        cdf = ss.lognorm.cdf(X, self.sigma, self.gamma, np.exp(self.mu))

        if show_plot == False:
            return cdf
        else:
            plt.plot(X, cdf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Fraction failing')
            text_title = str('Lognormal Distribution\n' + ' Cumulative Distribution Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return cdf

    def SF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the SF (survival function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        sf = ss.lognorm.sf(X, self.sigma, self.gamma, np.exp(self.mu))

        if show_plot == False:
            return sf
        else:
            plt.plot(X, sf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Fraction surviving')
            text_title = str('Lognormal Distribution\n' + ' Survival Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return sf

    def HF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the HF (hazard function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        hf = ss.lognorm.pdf(X, self.sigma, self.gamma, np.exp(self.mu)) / ss.lognorm.sf(X, self.sigma, self.gamma, np.exp(self.mu))

        if show_plot == False:
            return hf
        else:
            plt.plot(X, hf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Hazard')
            text_title = str('Lognormal Distribution\n' + ' Hazard Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return hf

    def CHF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the CHF (cumulative hazard function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        chf = -np.log(ss.lognorm.sf(X, self.sigma, self.gamma, np.exp(self.mu)))

        if show_plot == False:
            return chf
        else:
            plt.plot(X, chf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Cumulative hazard')
            text_title = str('Lognormal Distribution\n' + ' Cumulative Hazard Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return chf

    def quantile(self, q):
        '''
        Quantile calculator

        :param q: quantile to be calculated
        :return: the probability (area under the curve) that a random variable from the distribution is < q
        '''
        if type(q) == int or type(q) == float:
            if q < 0 or q > 1:
                raise ValueError('Quantile must be between 0 and 1')
        elif type(q) == np.ndarray or type(q) == list:
            if min(q) < 0 or max(q) > 1:
                raise ValueError('Quantile must be between 0 and 1')
        else:
            raise ValueError('Quantile must be of type int, float, list, array')
        return ss.lognorm.ppf(q, self.sigma, self.gamma, np.exp(self.mu))

    def inverse_SF(self, q):
        '''
        Inverse Survival function calculator

        :param q: quantile to be calculated
        :return: the inverse of the survival function at q
        '''
        if type(q) == int or type(q) == float:
            if q < 0 or q > 1:
                raise ValueError('Quantile must be between 0 and 1')
        elif type(q) == np.ndarray or type(q) == list:
            if min(q) < 0 or max(q) > 1:
                raise ValueError('Quantile must be between 0 and 1')
        else:
            raise ValueError('Quantile must be of type int, float, list, array')
        return ss.lognorm.isf(q, self.sigma, self.gamma, np.exp(self.mu))

    def mean_residual_life(self, t):
        '''
        Mean Residual Life calculator

        :param t: time at which MRL is to be evaluated
        :return: MRL
        '''
        R = lambda x: ss.lognorm.sf(x, self.sigma, self.gamma, np.exp(self.mu))
        integral_R, error = integrate.quad(R, t, np.inf)
        MRL = integral_R / R(t)
        return MRL

    def stats(self):
        if self.gamma == 0:
            print('Descriptive statistics for Lognormal distribution with mu =', self.mu, 'and sigma =', self.sigma)
        else:
            print('Descriptive statistics for Lognormal distribution with mu =', self.mu, ', sigma =', self.sigma, ', and gamma =', self.gamma)
        print('Mean = ', self.mean)
        print('Median =', self.median)
        print('Mode =', self.mode)
        print('5th quantile =', self.b5)
        print('95th quantile =', self.b95)
        print('Standard deviation =', self.standard_deviation)
        print('Variance =', self.variance)
        print('Skewness =', self.skewness)
        print('Excess kurtosis =', self.excess_kurtosis)

    def random_samples(self, number_of_samples, seed=None):
        '''
        random_samples
        Draws random samples from the probability distribution

        :param number_of_samples: the number of samples to be drawn
        :param seed: the random seed. Default is None
        :return: the random samples
        '''
        if type(number_of_samples) != int or number_of_samples < 1:
            raise ValueError('number_of_samples must be an integer greater than 1')
        if seed is not None:
            np.random.seed(seed)
        RVS = ss.lognorm.rvs(self.sigma, self.gamma, np.exp(self.mu), size=number_of_samples)
        return RVS


class Exponential_Distribution:
    '''
    Exponential probability distribution

    Creates a Distribution object.

    Inputs:
    Lambda - scale (rate) parameter
    gamma - threshold (offset) parameter. Default = 0

    methods:
    name - 'Exponential'
    name2 = 'Exponential_1P' or 'Exponential_2P' depending on the value of the gamma parameter
    param_title_long - Useful in plot titles, legends and in printing strings. eg. 'Exponential Distribution (λ=5)'
    param_title - Useful in plot titles, legends and in printing strings. eg. 'λ=5'
    parameters - [Lambda,gamma]
    Lambda
    gamma
    mean
    variance
    standard_deviation
    skewness
    kurtosis
    excess_kurtosis
    median
    mode
    b5
    b95
    plot() - plots all functions (PDF,CDF,SF,HF,CHF)
    PDF() - plots the probability density function
    CDF() - plots the cumulative distribution function
    SF() - plots the survival function (also known as reliability function)
    HF() - plots the hazard function
    CHF() - plots the cumulative hazard function
    quantile() - Calculates the quantile (time until a fraction has failed) for a given fraction failing.
                 Also known as b life where b5 is the time at which 5% have failed.
    inverse_SF() - the inverse of the Survival Function. This is useful when producing QQ plots.
    mean_residual_life() - Average residual lifetime of an item given that the item has survived up to a given time.
                           Effectively the mean of the remaining amount (right side) of a distribution at a given time.
    stats() - prints all the descriptive statistics. Same as the statistics shown using .plot() but printed to console.
    random_samples() - draws random samples from the distribution to which it is applied. Same as rvs in scipy.stats.
    '''

    def __init__(self, Lambda=None, gamma=0, **kwargs):
        self.name = 'Exponential'
        self.Lambda = Lambda
        if self.Lambda is None:
            raise ValueError('Parameter Lambda must be specified. Eg. Exponential_Distribution(Lambda=3)')
        self.gamma = gamma
        self.parameters = np.array([self.Lambda, self.gamma])
        mean, var, skew, kurt = ss.expon.stats(scale=1 / self.Lambda, loc=self.gamma, moments='mvsk')
        self.mean = float(mean)
        self.variance = float(var)
        self.standard_deviation = var ** 0.5
        self.skewness = float(skew)
        self.kurtosis = kurt + 3
        self.excess_kurtosis = float(kurt)
        self.median = ss.expon.median(scale=1 / self.Lambda, loc=self.gamma)
        self.mode = self.gamma
        if self.gamma != 0:
            self.param_title = str('λ=' + str(round_to_decimals(self.Lambda, dec)) + ',γ=' + str(round_to_decimals(self.gamma, dec)))
            self.param_title_long = str('Exponential Distribution (λ=' + str(round_to_decimals(self.Lambda, dec)) + ',γ=' + str(round_to_decimals(gamma, dec)) + ')')
            self.name2 = 'Exponential_2P'
        else:
            self.param_title = str('λ=' + str(round_to_decimals(self.Lambda, dec)))
            self.param_title_long = str('Exponential Distribution (λ=' + str(round_to_decimals(self.Lambda, dec)) + ')')
            self.name2 = 'Exponential_1P'
        self.b5 = ss.expon.ppf(0.05, scale=1 / self.Lambda, loc=self.gamma)
        self.b95 = ss.expon.ppf(0.95, scale=1 / self.Lambda, loc=self.gamma)

        # extracts values for confidence interval plotting
        if 'Lambda_SE' in kwargs:
            self.Lambda_SE = kwargs.pop('Lambda_SE')
        else:
            self.Lambda_SE = None
        if 'CI' in kwargs:
            CI = kwargs.pop('CI')
            self.Z = -ss.norm.ppf((1 - CI) / 2)
        else:
            self.Z = None
        for item in kwargs.keys():
            print('WARNING:', item, 'not recognised as an appropriate entry in kwargs. Appropriate entries are Lambda_SE and CI')

    def plot(self, xvals=None, xmin=None, xmax=None):
        '''
        Plots all functions (PDF, CDF, SF, HF, CHF) and descriptive statistics in a single figure

        Inputs:
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *no plotting keywords are accepted

        Outputs:
        The plot will be shown. No need to use plt.show()
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        pdf = ss.expon.pdf(X, scale=1 / self.Lambda, loc=self.gamma)
        cdf = ss.expon.cdf(X, scale=1 / self.Lambda, loc=self.gamma)
        sf = ss.expon.sf(X, scale=1 / self.Lambda, loc=self.gamma)
        hf = pdf / sf
        chf = -np.log(sf)

        plt.figure(figsize=(9, 7))
        text_title = str('Exponential Distribution' + '\n' + self.param_title)
        plt.suptitle(text_title, fontsize=15)
        plt.subplot(231)
        plt.plot(X, pdf)
        plt.title('Probability Density\nFunction')
        plt.subplot(232)
        plt.plot(X, cdf)
        plt.title('Cumulative Distribution\nFunction')
        plt.subplot(233)
        plt.plot(X, sf)
        plt.title('Survival Function')
        plt.subplot(234)
        plt.plot(X, hf)
        plt.title('Hazard Function')
        plt.subplot(235)
        plt.plot(X, chf)
        plt.title('Cumulative Hazard\nFunction')

        # descriptive statistics section
        plt.subplot(236)
        plt.axis('off')
        plt.ylim([0, 10])
        plt.xlim([0, 10])
        text_mean = str('Mean = ' + str(round_to_decimals(float(self.mean), dec)))
        text_median = str('Median = ' + str(round_to_decimals(self.median, dec)))
        try:
            text_mode = str('Mode = ' + str(round_to_decimals(self.mode, dec)))
        except:
            text_mode = str('Mode = ' + str(self.mode))  # required when mode is str
        text_b5 = str('$5^{th}$ quantile = ' + str(round_to_decimals(self.b5, dec)))
        text_b95 = str('$95^{th}$ quantile = ' + str(round_to_decimals(self.b95, dec)))
        text_std = str('Standard deviation = ' + str(round_to_decimals(self.variance ** 0.5, dec)))
        text_var = str('Variance = ' + str(round_to_decimals(float(self.variance), dec)))
        text_skew = str('Skewness = ' + str(round_to_decimals(float(self.skewness), dec)))
        text_ex_kurt = str('Excess kurtosis = ' + str(round_to_decimals(float(self.excess_kurtosis), dec)))
        plt.text(0, 9, text_mean)
        plt.text(0, 8, text_median)
        plt.text(0, 7, text_mode)
        plt.text(0, 6, text_b5)
        plt.text(0, 5, text_b95)
        plt.text(0, 4, text_std)
        plt.text(0, 3, text_var)
        plt.text(0, 2, text_skew)
        plt.text(0, 1, text_ex_kurt)
        plt.tight_layout()
        plt.subplots_adjust(hspace=0.3, top=0.84)
        plt.show()

    def PDF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the PDF (probability density function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        pdf = ss.expon.pdf(X, scale=1 / self.Lambda, loc=self.gamma)

        if show_plot == False:
            return pdf
        else:
            plt.plot(X, pdf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Probability density')
            text_title = str('Exponential Distribution\n' + ' Probability Density Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)

            return pdf

    def CDF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the CDF (cumulative distribution function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        If the distribution object contains Lambda_lower and Lambda_upper, the CI bounds will be plotted. The bounds for the CI are the same as the Fitter was given (default is 0.95). To hide the CI bounds specify show_CI=False

        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        ####this determines if the user has specified for the CI bounds to be shown or hidden. kwargs are show_CI or plot_CI
        kwargs_list = kwargs.keys()
        if 'plot_CI' in kwargs_list:
            plot_CI = kwargs.pop('plot_CI')
        elif 'show_CI' in kwargs_list:
            plot_CI = kwargs.pop('show_CI')
        else:
            plot_CI = True  # default
        if 'CI_type' in kwargs_list:
            kwargs.pop('CI_type')
            print('WARNING: CI_type is not required for the Exponential distribution since the confidence intervals of time and reliability are identical')
        if plot_CI not in [True, False]:
            print('WARNING: unexpected value in kwargs. To show/hide the CI you can specify either show_CI=True/False or plot_CI=True/False')
            plot_CI = True

        cdf = ss.expon.cdf(X, scale=1 / self.Lambda, loc=self.gamma)
        if show_plot == False:
            return cdf
        else:
            xrange = plt.xlim()  #### this ensures the previously plotted objects are considered when setting the range
            p = plt.plot(X, cdf, **kwargs)  ####
            plt.xlabel('x values')
            plt.ylabel('Fraction failing')
            text_title = str('Exponential Distribution\n' + ' Cumulative Distribution Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)

            Exponential_Distribution.__expon_CI(self, func='CDF', plot_CI=plot_CI, text_title=text_title, color=p[0].get_color())

            return cdf

    def SF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the SF (survival function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        If the distribution object contains Lambda_lower and Lambda_upper, the CI bounds will be plotted. The bounds for the CI are the same as the Fitter was given (default is 0.95). To hide the CI bounds specify show_CI=False
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        # this determines if the user has specified for the CI bounds to be shown or hidden. kwargs are show_CI or plot_CI
        kwargs_list = kwargs.keys()
        if 'plot_CI' in kwargs_list:
            plot_CI = kwargs.pop('plot_CI')
        elif 'show_CI' in kwargs_list:
            plot_CI = kwargs.pop('show_CI')
        else:
            plot_CI = True  # default
        if 'CI_type' in kwargs_list:
            kwargs.pop('CI_type')
            print('WARNING: CI_type is not required for the Exponential distribution since the confidence intervals of time and reliability are identical')
        if plot_CI not in [True, False]:
            print('WARNING: unexpected value in kwargs. To show/hide the CI you can specify either show_CI=True/False or plot_CI=True/False')
            plot_CI = True

        sf = ss.expon.sf(X, scale=1 / self.Lambda, loc=self.gamma)
        if show_plot == False:
            return sf
        else:
            p = plt.plot(X, sf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Fraction surviving')
            text_title = str('Exponential Distribution\n' + ' Survival Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)

            Exponential_Distribution.__expon_CI(self, func='SF', plot_CI=plot_CI, text_title=text_title, color=p[0].get_color())

            return sf

    def HF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the HF (hazard function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        # this determines if the user has specified for the CI bounds to be shown or hidden. kwargs are show_CI or plot_CI
        kwargs_list = kwargs.keys()
        if 'plot_CI' in kwargs_list:
            plot_CI = kwargs.pop('plot_CI')
        elif 'show_CI' in kwargs_list:
            plot_CI = kwargs.pop('show_CI')
        else:
            plot_CI = True  # default
        if 'CI_type' in kwargs_list:
            kwargs.pop('CI_type')
            print('WARNING: CI_type is not required for the Exponential distribution since the confidence intervals of time and reliability are identical')
        if plot_CI not in [True, False]:
            print('WARNING: unexpected value in kwargs. To show/hide the CI you can specify either show_CI=True/False or plot_CI=True/False')
            plot_CI = True

        hf = ss.expon.pdf(X, scale=1 / self.Lambda, loc=self.gamma) / ss.expon.sf(X, scale=1 / self.Lambda, loc=self.gamma)
        if show_plot == False:
            return hf
        else:
            p = plt.plot(X, hf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Hazard')
            text_title = str('Exponential Distribution\n' + ' Hazard Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)

            Exponential_Distribution.__expon_CI(self, func='HF', plot_CI=plot_CI, text_title=text_title, color=p[0].get_color())

            return hf

    def CHF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the CHF (cumulative hazard function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        If the distribution object contains Lambda_lower and Lambda_upper, the CI bounds will be plotted. The bounds for the CI are the same as the Fitter was given (default is 0.95). To hide the CI bounds specify show_CI=False
        '''

        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        # this determines if the user has specified for the CI bounds to be shown or hidden. kwargs are show_CI or plot_CI
        kwargs_list = kwargs.keys()
        if 'plot_CI' in kwargs_list:
            plot_CI = kwargs.pop('plot_CI')
        elif 'show_CI' in kwargs_list:
            plot_CI = kwargs.pop('show_CI')
        else:
            plot_CI = True  # default
        if 'CI_type' in kwargs_list:
            kwargs.pop('CI_type')
            print('WARNING: CI_type is not required for the Exponential distribution since the confidence intervals of time and reliability are identical')
        if plot_CI not in [True, False]:
            print('WARNING: unexpected value in kwargs. To show/hide the CI you can specify either show_CI=True/False or plot_CI=True/False')
            plot_CI = True

        chf = -np.log(ss.expon.sf(X, scale=1 / self.Lambda, loc=self.gamma))
        if show_plot == False:
            return chf
        else:
            p = plt.plot(X, chf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Cumulative hazard')
            text_title = str('Exponential Distribution\n' + ' Cumulative Hazard Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)

            Exponential_Distribution.__expon_CI(self, func='CHF', plot_CI=plot_CI, text_title=text_title, color=p[0].get_color())

            return chf

    def __expon_CI(self, func, plot_CI, text_title, color):
        '''
        Generates the confidence intervals for CDF, SF, HF, CHF
        This is a hidden function intended only for use by other functions.
        '''
        if func not in ['CDF', 'SF', 'HF', 'CHF']:
            raise ValueError('func must be either CDF, SF, HF, or CHF')

        # determine the xrange so it can be reimposed after plotting the CI. Without this the xrange gets too large as some CI can extend a long way.
        x_lims = plt.xlim()
        y_lims = plt.ylim()

        # this section plots the confidence interval
        if self.Lambda_SE is not None and self.Z is not None and plot_CI is True:

            # add a line to the plot title to include the confidence bounds information
            CI_100 = round((1 - ss.norm.cdf(-self.Z) * 2) * 100, 4)  # Converts Z to CI and formats the confidence interval value ==> 0.95 becomes 95
            if CI_100 % 1 == 0:
                CI_100 = int(CI_100)  # removes decimals if the only decimal is 0
            text_title = str(text_title + '\n' + str(CI_100) + '% confidence bounds')  # Adds the CI and CI_type to the title
            plt.title(text_title)
            plt.subplots_adjust(top=0.83)

            Lambda_upper = self.Lambda * (np.exp(self.Z * (self.Lambda_SE / self.Lambda)))
            Lambda_lower = self.Lambda * (np.exp(-self.Z * (self.Lambda_SE / self.Lambda)))
            t_max = self.b95 * 5
            t = np.geomspace(1e-5, t_max, 1000)

            if func == 'CDF':
                yy_upper0 = 1 - np.exp(-Lambda_upper * t)
                yy_lower0 = 1 - np.exp(-Lambda_lower * t)
                y_max = 1 - 1e-8
                y_min = 1e-8
            if func == 'SF':
                yy_upper0 = np.exp(-Lambda_upper * t)
                yy_lower0 = np.exp(-Lambda_lower * t)
                y_max = 1 - 1e-8
                y_min = 1e-8
            if func == 'HF':
                yy_upper0 = Lambda_upper * np.ones_like(t)
                yy_lower0 = Lambda_lower * np.ones_like(t)
                y_min = 0
                y_max = Lambda_upper * 2
                y_lims = (0, Lambda_upper * 1.2)  # this changes the ylims to ensure the CI will be included as it is a constant
            if func == 'CHF':
                yy_upper0 = -np.log(np.exp(-Lambda_upper * t))  # same as -np.log(SF)
                yy_lower0 = -np.log(np.exp(-Lambda_lower * t))
                y_min = 0
                y_max = 1e10
            # impose plotting limits so not plotting to infinity
            yy_lower = np.where(yy_lower0 < y_min, y_min, np.where(yy_lower0 > y_max, y_max, yy_lower0))
            yy_upper = np.where(yy_upper0 < y_min, y_min, np.where(yy_upper0 > y_max, y_max, yy_upper0))
            plt.fill_between(t + self.gamma, yy_lower, yy_upper, color=color, alpha=0.3, linewidth=0)  # the linewidth and linestyle hides the boundary between the two parts
            plt.plot(t + self.gamma, yy_lower, color=color, linewidth=0)
            plt.plot(t + self.gamma, yy_upper, color=color, linewidth=0)
            # reimpose the xlim and ylim to be what it was before the CI was plotted
            plt.xlim(x_lims)
            plt.ylim(y_lims)

    def quantile(self, q):
        '''
        Quantile calculator

        :param q: quantile to be calculated
        :return: the probability (area under the curve) that a random variable from the distribution is < q
        '''
        if type(q) == int or type(q) == float:
            if q < 0 or q > 1:
                raise ValueError('Quantile must be between 0 and 1')
        elif type(q) == np.ndarray or type(q) == list:
            if min(q) < 0 or max(q) > 1:
                raise ValueError('Quantile must be between 0 and 1')
        else:
            raise ValueError('Quantile must be of type int, float, list, array')
        return ss.expon.ppf(q, scale=1 / self.Lambda, loc=self.gamma)

    def inverse_SF(self, q):
        '''
        Inverse Survival function calculator

        :param q: quantile to be calculated
        :return: the inverse of the survival function at q
        '''
        if type(q) == int or type(q) == float:
            if q < 0 or q > 1:
                raise ValueError('Quantile must be between 0 and 1')
        elif type(q) == np.ndarray or type(q) == list:
            if min(q) < 0 or max(q) > 1:
                raise ValueError('Quantile must be between 0 and 1')
        else:
            raise ValueError('Quantile must be of type int, float, list, array')
        return ss.expon.isf(q, scale=1 / self.Lambda, loc=self.gamma)

    def mean_residual_life(self, t):
        '''
        Mean Residual Life calculator

        :param t: time at which MRL is to be evaluated
        :return: MRL
        '''
        R = lambda x: ss.expon.sf(x, scale=1 / self.Lambda, loc=self.gamma)
        integral_R, error = integrate.quad(R, t, np.inf)
        MRL = integral_R / R(t)
        return MRL

    def stats(self):
        if self.gamma == 0:
            print('Descriptive statistics for Exponential distribution with lambda =', self.Lambda)
        else:
            print('Descriptive statistics for Exponential distribution with lambda =', self.Lambda, ', and gamma =', self.gamma)
        print('Mean = ', self.mean)
        print('Median =', self.median)
        print('Mode =', self.mode)
        print('5th quantile =', self.b5)
        print('95th quantile =', self.b95)
        print('Standard deviation =', self.standard_deviation)
        print('Variance =', self.variance)
        print('Skewness =', self.skewness)
        print('Excess kurtosis =', self.excess_kurtosis)

    def random_samples(self, number_of_samples, seed=None):
        '''
        random_samples
        Draws random samples from the probability distribution

        :param number_of_samples: the number of samples to be drawn
        :param seed: the random seed. Default is None
        :return: the random samples
        '''
        if type(number_of_samples) != int or number_of_samples < 1:
            raise ValueError('number_of_samples must be an integer greater than 1')
        if seed is not None:
            np.random.seed(seed)
        RVS = ss.expon.rvs(scale=1 / self.Lambda, loc=self.gamma, size=number_of_samples)
        return RVS


class Gamma_Distribution:
    '''
    Gamma probability distribution

    Creates a Distribution object.

    inputs:
    alpha - scale parameter
    beta - shape parameter
    gamma - threshold (offset) parameter. Default = 0

    methods:
    name - 'Gamma'
    name2 = 'Gamma_2P' or 'Gamma_3P' depending on the value of the gamma parameter
    param_title_long - Useful in plot titles, legends and in printing strings. eg. 'Gamma Distribution (α=5,β=2)'
    param_title - Useful in plot titles, legends and in printing strings. eg. 'α=5,β=2'
    parameters - [alpha,beta,gamma]
    alpha
    beta
    gamma
    mean
    variance
    standard_deviation
    skewness
    kurtosis
    excess_kurtosis
    median
    mode
    b5
    b95
    plot() - plots all functions (PDF,CDF,SF,HF,CHF)
    PDF() - plots the probability density function
    CDF() - plots the cumulative distribution function
    SF() - plots the survival function (also known as reliability function)
    HF() - plots the hazard function
    CHF() - plots the cumulative hazard function
    quantile() - Calculates the quantile (time until a fraction has failed) for a given fraction failing.
                 Also known as b life where b5 is the time at which 5% have failed.
    inverse_SF() - the inverse of the Survival Function. This is useful when producing QQ plots.
    mean_residual_life() - Average residual lifetime of an item given that the item has survived up to a given time.
                           Effectively the mean of the remaining amount (right side) of a distribution at a given time.
    stats() - prints all the descriptive statistics. Same as the statistics shown using .plot() but printed to console.
    random_samples() - draws random samples from the distribution to which it is applied. Same as rvs in scipy.stats.
    '''

    def __init__(self, alpha=None, beta=None, gamma=0):
        self.name = 'Gamma'
        self.alpha = alpha
        self.beta = beta
        if self.alpha is None or self.beta is None:
            raise ValueError('Parameters alpha and beta must be specified. Eg. Gamma_Distribution(alpha=5,beta=2)')
        self.gamma = gamma
        self.parameters = np.array([self.alpha, self.beta, self.gamma])
        mean, var, skew, kurt = ss.gamma.stats(self.beta, scale=self.alpha, loc=self.gamma, moments='mvsk')
        self.mean = float(mean)
        self.variance = float(var)
        self.standard_deviation = var ** 0.5
        self.skewness = float(skew)
        self.kurtosis = kurt + 3
        self.excess_kurtosis = float(kurt)
        self.median = ss.gamma.median(self.beta, scale=self.alpha, loc=self.gamma)
        if self.beta >= 1:
            self.mode = (self.beta - 1) * self.alpha + self.gamma
        else:
            self.mode = r'No mode exists when $\beta$ < 1'
        if self.gamma != 0:
            self.param_title = str('α=' + str(round_to_decimals(self.alpha, dec)) + ',β=' + str(round_to_decimals(self.beta, dec)) + ',γ=' + str(round_to_decimals(self.gamma, dec)))
            self.param_title_long = str('Gamma Distribution (α=' + str(round_to_decimals(self.alpha, dec)) + ',β=' + str(round_to_decimals(self.beta, dec)) + ',γ=' + str(round_to_decimals(self.gamma, dec)) + ')')
            self.name2 = 'Gamma_3P'
        else:
            self.param_title = str('α=' + str(round_to_decimals(self.alpha, dec)) + ',β=' + str(round_to_decimals(self.beta, dec)))
            self.param_title_long = str('Gamma Distribution (α=' + str(round_to_decimals(self.alpha, dec)) + ',β=' + str(round_to_decimals(self.beta, dec)) + ')')
            self.name2 = 'Gamma_2P'
        self.b5 = ss.gamma.ppf(0.05, self.beta, scale=self.alpha, loc=self.gamma)
        self.b95 = ss.gamma.ppf(0.95, self.beta, scale=self.alpha, loc=self.gamma)

    def plot(self, xvals=None, xmin=None, xmax=None):
        '''
        Plots all functions (PDF, CDF, SF, HF, CHF) and descriptive statistics in a single figure

        Inputs:
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *no plotting keywords are accepted

        Outputs:
        The plot will be shown. No need to use plt.show()
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        pdf = ss.gamma.pdf(X, self.beta, scale=self.alpha, loc=self.gamma)
        cdf = ss.gamma.cdf(X, self.beta, scale=self.alpha, loc=self.gamma)
        sf = ss.gamma.sf(X, self.beta, scale=self.alpha, loc=self.gamma)
        hf = pdf / sf
        chf = -np.log(sf)

        plt.figure(figsize=(9, 7))
        text_title = str('Gamma Distribution' + '\n' + self.param_title)
        plt.suptitle(text_title, fontsize=15)
        plt.subplot(231)
        plt.plot(X, pdf)
        plt.title('Probability Density\nFunction')
        plt.subplot(232)
        plt.plot(X, cdf)
        plt.title('Cumulative Distribution\nFunction')
        plt.subplot(233)
        plt.plot(X, sf)
        plt.title('Survival Function')
        plt.subplot(234)
        plt.plot(X, hf)
        plt.title('Hazard Function')
        plt.subplot(235)
        plt.plot(X, chf)
        plt.title('Cumulative Hazard\nFunction')

        # descriptive statistics section
        plt.subplot(236)
        plt.axis('off')
        plt.ylim([0, 10])
        plt.xlim([0, 10])
        text_mean = str('Mean = ' + str(round_to_decimals(float(self.mean), dec)))
        text_median = str('Median = ' + str(round_to_decimals(self.median, dec)))
        try:
            text_mode = str('Mode = ' + str(round_to_decimals(self.mode, dec)))
        except:
            text_mode = str('Mode = ' + str(self.mode))  # required when mode is str
        text_b5 = str('$5^{th}$ quantile = ' + str(round_to_decimals(self.b5, dec)))
        text_b95 = str('$95^{th}$ quantile = ' + str(round_to_decimals(self.b95, dec)))
        text_std = str('Standard deviation = ' + str(round_to_decimals(self.variance ** 0.5, dec)))
        text_var = str('Variance = ' + str(round_to_decimals(float(self.variance), dec)))
        text_skew = str('Skewness = ' + str(round_to_decimals(float(self.skewness), dec)))
        text_ex_kurt = str('Excess kurtosis = ' + str(round_to_decimals(float(self.excess_kurtosis), dec)))
        plt.text(0, 9, text_mean)
        plt.text(0, 8, text_median)
        plt.text(0, 7, text_mode)
        plt.text(0, 6, text_b5)
        plt.text(0, 5, text_b95)
        plt.text(0, 4, text_std)
        plt.text(0, 3, text_var)
        plt.text(0, 2, text_skew)
        plt.text(0, 1, text_ex_kurt)
        plt.tight_layout()
        plt.subplots_adjust(hspace=0.3, top=0.84)
        plt.show()

    def PDF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the PDF (probability density function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        pdf = ss.gamma.pdf(X, self.beta, scale=self.alpha, loc=self.gamma)

        if show_plot == False:
            return pdf
        else:
            plt.plot(X, pdf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Probability density')
            text_title = str('Gamma Distribution\n' + ' Probability Density Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return pdf

    def CDF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the CDF (cumulative distribution function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        cdf = ss.gamma.cdf(X, self.beta, scale=self.alpha, loc=self.gamma)

        if show_plot == False:
            return cdf
        else:
            plt.plot(X, cdf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Fraction failing')
            text_title = str('Gamma Distribution\n' + ' Cumulative Distribution Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return cdf

    def SF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the SF (survival function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        sf = ss.gamma.sf(X, self.beta, scale=self.alpha, loc=self.gamma)

        if show_plot == False:
            return sf
        else:
            plt.plot(X, sf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Fraction surviving')
            text_title = str('Gamma Distribution\n' + ' Survival Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return sf

    def HF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the HF (hazard function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        hf = ss.gamma.pdf(X, self.beta, scale=self.alpha, loc=self.gamma) / ss.gamma.sf(X, self.beta, scale=self.alpha, loc=self.gamma)

        if show_plot == False:
            return hf
        else:
            plt.plot(X, hf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Hazard')
            text_title = str('Gamma Distribution\n' + ' Hazard Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return hf

    def CHF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the CHF (cumulative hazard function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, self.b95 * 1.5, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0:
                raise ValueError('the value given for xvals is less than 0')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and min(X) < 0:
            raise ValueError('xvals was found to contain values below 0')

        chf = -np.log(ss.gamma.sf(X, self.beta, scale=self.alpha, loc=self.gamma))

        if show_plot == False:
            return chf
        else:
            plt.plot(X, chf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Cumulative hazard')
            text_title = str('Gamma Distribution\n' + ' Cumulative Hazard Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return chf

    def quantile(self, q):
        '''
        Quantile calculator

        :param q: quantile to be calculated
        :return: the probability (area under the curve) that a random variable from the distribution is < q
        '''
        if type(q) == int or type(q) == float:
            if q < 0 or q > 1:
                raise ValueError('Quantile must be between 0 and 1')
        elif type(q) == np.ndarray or type(q) == list:
            if min(q) < 0 or max(q) > 1:
                raise ValueError('Quantile must be between 0 and 1')
        else:
            raise ValueError('Quantile must be of type int, float, list, array')
        return ss.gamma.ppf(q, self.beta, scale=self.alpha, loc=self.gamma)

    def inverse_SF(self, q):
        '''
        Inverse Survival function calculator

        :param q: quantile to be calculated
        :return: the inverse of the survival function at q
        '''
        if type(q) == int or type(q) == float:
            if q < 0 or q > 1:
                raise ValueError('Quantile must be between 0 and 1')
        elif type(q) == np.ndarray or type(q) == list:
            if min(q) < 0 or max(q) > 1:
                raise ValueError('Quantile must be between 0 and 1')
        else:
            raise ValueError('Quantile must be of type int, float, list, array')
        return ss.gamma.isf(q, self.beta, scale=self.alpha, loc=self.gamma)

    def mean_residual_life(self, t):
        '''
        Mean Residual Life calculator

        :param t: time at which MRL is to be evaluated
        :return: MRL
        '''
        R = lambda x: ss.gamma.sf(x, self.beta, scale=self.alpha, loc=self.gamma)
        integral_R, error = integrate.quad(R, t, np.inf)
        MRL = integral_R / R(t)
        return MRL

    def stats(self):
        if self.gamma == 0:
            print('Descriptive statistics for Gamma distribution with alpha =', self.alpha, 'and beta =', self.beta)
        else:
            print('Descriptive statistics for Gamma distribution with alpha =', self.alpha, ', beta =', self.beta, ', and gamma =', self.gamma)
        print('Mean = ', self.mean)
        print('Median =', self.median)
        print('Mode =', self.mode)
        print('5th quantile =', self.b5)
        print('95th quantile =', self.b95)
        print('Standard deviation =', self.standard_deviation)
        print('Variance =', self.variance)
        print('Skewness =', self.skewness)
        print('Excess kurtosis =', self.excess_kurtosis)

    def random_samples(self, number_of_samples, seed=None):
        '''
        random_samples
        Draws random samples from the probability distribution

        :param number_of_samples: the number of samples to be drawn
        :param seed: the random seed. Default is None
        :return: the random samples
        '''
        if type(number_of_samples) != int or number_of_samples < 1:
            raise ValueError('number_of_samples must be an integer greater than 1')
        if seed is not None:
            np.random.seed(seed)
        RVS = ss.gamma.rvs(self.beta, scale=self.alpha, loc=self.gamma, size=number_of_samples)
        return RVS


class Beta_Distribution:
    '''
    Beta probability distribution

    Creates a Distribution object in the range 0-1.

    inputs:
    alpha - shape parameter 1
    beta - shape parameter 2

    methods:
    name - 'Beta'
    name2 = 'Beta_2P'
    param_title_long - Useful in plot titles, legends and in printing strings. eg. 'Beta Distribution (α=5,β=2)'
    param_title - Useful in plot titles, legends and in printing strings. eg. 'α=5,β=2'
    parameters - [alpha,beta]
    alpha
    beta
    mean
    variance
    standard_deviation
    skewness
    kurtosis
    excess_kurtosis
    median
    mode
    b5
    b95
    plot() - plots all functions (PDF,CDF,SF,HF,CHF)
    PDF() - plots the probability density function
    CDF() - plots the cumulative distribution function
    SF() - plots the survival function (also known as reliability function)
    HF() - plots the hazard function
    CHF() - plots the cumulative hazard function
    quantile() - Calculates the quantile (time until a fraction has failed) for a given fraction failing.
                 Also known as b life where b5 is the time at which 5% have failed.
    inverse_SF() - the inverse of the Survival Function. This is useful when producing QQ plots.
    mean_residual_life() - Average residual lifetime of an item given that the item has survived up to a given time.
                           Effectively the mean of the remaining amount (right side) of a distribution at a given time.
    stats() - prints all the descriptive statistics. Same as the statistics shown using .plot() but printed to console.
    random_samples() - draws random samples from the distribution to which it is applied. Same as rvs in scipy.stats.
    '''

    def __init__(self, alpha=None, beta=None):
        self.name = 'Beta'
        self.name2 = 'Beta_2P'
        self.alpha = alpha
        self.beta = beta
        if self.alpha is None or self.beta is None:
            raise ValueError('Parameters alpha and beta must be specified. Eg. Beta_Distribution(alpha=5,beta=2)')
        self.parameters = np.array([self.alpha, self.beta])
        mean, var, skew, kurt = ss.beta.stats(self.alpha, self.beta, 0, 1, moments='mvsk')
        self.mean = float(mean)
        self.variance = float(var)
        self.standard_deviation = var ** 0.5
        self.skewness = float(skew)
        self.kurtosis = kurt + 3
        self.excess_kurtosis = float(kurt)
        self.median = ss.beta.median(self.alpha, self.beta, 0, 1)
        if self.alpha > 1 and self.beta > 1:
            self.mode = (self.alpha - 1) / (self.beta + self.alpha - 2)
        else:
            self.mode = r'No mode exists unless $\alpha$ > 1 and $\beta$ > 1'
        self.param_title = str('α=' + str(round_to_decimals(self.alpha, dec)) + ',β=' + str(round_to_decimals(self.beta, dec)))
        self.param_title_long = str('Beta Distribution (α=' + str(round_to_decimals(self.alpha, dec)) + ',β=' + str(round_to_decimals(self.beta, dec)) + ')')
        self.b5 = ss.beta.ppf(0.05, self.alpha, self.beta, 0, 1)
        self.b95 = ss.beta.ppf(0.95, self.alpha, self.beta, 0, 1)

    def plot(self, xvals=None, xmin=None, xmax=None):
        '''
        Plots all functions (PDF, CDF, SF, HF, CHF) and descriptive statistics in a single figure

        Inputs:
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *no plotting keywords are accepted

        Outputs:
        The plot will be shown. No need to use plt.show()
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, 1, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0 or X > 1:
                raise ValueError('the value given for xvals is less than 0 or greater than 1')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and (min(X) < 0 or max(X) > 1):
            raise ValueError('xvals was found to contain values below 0 or greater than 1')

        pdf = ss.beta.pdf(X, self.alpha, self.beta, 0, 1)
        cdf = ss.beta.cdf(X, self.alpha, self.beta, 0, 1)
        sf = ss.beta.sf(X, self.alpha, self.beta, 0, 1)
        hf = pdf / sf
        chf = -np.log(sf)

        plt.figure(figsize=(9, 7))
        text_title = str('Beta Distribution' + '\n' + self.param_title)
        plt.suptitle(text_title, fontsize=15)
        plt.subplot(231)
        plt.plot(X, pdf)
        plt.title('Probability Density\nFunction')
        plt.subplot(232)
        plt.plot(X, cdf)
        plt.title('Cumulative Distribution\nFunction')
        plt.subplot(233)
        plt.plot(X, sf)
        plt.title('Survival Function')
        plt.subplot(234)
        plt.plot(X, hf)
        plt.title('Hazard Function')
        plt.subplot(235)
        plt.plot(X, chf)
        plt.title('Cumulative Hazard\nFunction')

        # descriptive statistics section
        plt.subplot(236)
        plt.axis('off')
        plt.ylim([0, 10])
        plt.xlim([0, 10])
        text_mean = str('Mean = ' + str(round_to_decimals(float(self.mean), dec)))
        text_median = str('Median = ' + str(round_to_decimals(self.median, dec)))
        try:
            text_mode = str('Mode = ' + str(round_to_decimals(self.mode, dec)))
        except:
            text_mode = str('Mode = ' + str(self.mode))  # required when mode is str
        text_b5 = str('$5^{th}$ quantile = ' + str(round_to_decimals(self.b5, dec)))
        text_b95 = str('$95^{th}$ quantile = ' + str(round_to_decimals(self.b95, dec)))
        text_std = str('Standard deviation = ' + str(round_to_decimals(self.variance ** 0.5, dec)))
        text_var = str('Variance = ' + str(round_to_decimals(float(self.variance), dec)))
        text_skew = str('Skewness = ' + str(round_to_decimals(float(self.skewness), dec)))
        text_ex_kurt = str('Excess kurtosis = ' + str(round_to_decimals(float(self.excess_kurtosis), dec)))
        plt.text(0, 9, text_mean)
        plt.text(0, 8, text_median)
        plt.text(0, 7, text_mode)
        plt.text(0, 6, text_b5)
        plt.text(0, 5, text_b95)
        plt.text(0, 4, text_std)
        plt.text(0, 3, text_var)
        plt.text(0, 2, text_skew)
        plt.text(0, 1, text_ex_kurt)
        plt.tight_layout()
        plt.subplots_adjust(hspace=0.3, top=0.84)
        plt.show()

    def PDF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the PDF (probability density function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, 1, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0 or X > 1:
                raise ValueError('the value given for xvals is less than 0 or greater than 1')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and (min(X) < 0 or max(X) > 1):
            raise ValueError('xvals was found to contain values below 0 or greater than 1')

        pdf = ss.beta.pdf(X, self.alpha, self.beta, 0, 1)

        if show_plot == False:
            return pdf
        else:
            plt.plot(X, pdf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Probability density')
            text_title = str('Beta Distribution\n' + ' Probability Density Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return pdf

    def CDF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the CDF (cumulative distribution function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, 1, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0 or X > 1:
                raise ValueError('the value given for xvals is less than 0 or greater than 1')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and (min(X) < 0 or max(X) > 1):
            raise ValueError('xvals was found to contain values below 0 or greater than 1')

        cdf = ss.beta.cdf(X, self.alpha, self.beta, 0, 1)

        if show_plot == False:
            return cdf
        else:
            plt.plot(X, cdf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Fraction failing')
            text_title = str('Beta Distribution\n' + ' Cumulative Distribution Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return cdf

    def SF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the SF (survival function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, 1, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0 or X > 1:
                raise ValueError('the value given for xvals is less than 0 or greater than 1')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and (min(X) < 0 or max(X) > 1):
            raise ValueError('xvals was found to contain values below 0 or greater than 1')

        sf = ss.beta.sf(X, self.alpha, self.beta, 0, 1)

        if show_plot == False:
            return sf
        else:
            plt.plot(X, sf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Fraction surviving')
            text_title = str('Beta Distribution\n' + ' Survival Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return sf

    def HF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the HF (hazard function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, 1, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0 or X > 1:
                raise ValueError('the value given for xvals is less than 0 or greater than 1')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and (min(X) < 0 or max(X) > 1):
            raise ValueError('xvals was found to contain values below 0 or greater than 1')

        hf = ss.beta.pdf(X, self.alpha, self.beta, 0, 1) / ss.beta.sf(X, self.alpha, self.beta, 0, 1)

        if show_plot == False:
            return hf
        else:
            plt.plot(X, hf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Hazard')
            text_title = str('Beta Distribution\n' + ' Hazard Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return hf

    def CHF(self, xvals=None, xmin=None, xmax=None, show_plot=True, **kwargs):
        '''
        Plots the CHF (cumulative hazard function)

        Inputs:
        show_plot - True/False. Default is True
        xvals - x-values for plotting
        xmin - minimum x-value for plotting
        xmax - maximum x-value for plotting
        *If xvals is specified, it will be used. If xvals is not specified but xmin and xmax are specified then an array with 1000 elements
        will be created using these ranges. If nothing is specified then the range will be based on the distribution's parameters.
        *plotting keywords are also accepted (eg. color, linestyle)

        Outputs:
        yvals - this is the y-values of the plot
        The plot will be shown if show_plot is True (which it is by default).
        '''
        if xvals is not None:
            X = xvals
        elif xmin is not None and xmax is not None:
            X = np.linspace(xmin, xmax, 1000)
        else:
            X = np.linspace(0, 1, 1000)  # if no limits are specified, they are assumed
        if type(X) in [float, int, np.float64]:
            if X < 0 or X > 1:
                raise ValueError('the value given for xvals is less than 0 or greater than 1')
        elif type(X) is list:
            X = np.array(X)
        elif type(X) is np.ndarray:
            pass
        else:
            raise ValueError('unexpected type in xvals. Must be int, float, list, or array')
        if type(X) is np.ndarray and (min(X) < 0 or max(X) > 1):
            raise ValueError('xvals was found to contain values below 0 or greater than 1')

        chf = -np.log(ss.beta.sf(X, self.alpha, self.beta, 0, 1))

        if show_plot == False:
            return chf
        else:
            plt.plot(X, chf, **kwargs)
            plt.xlabel('x values')
            plt.ylabel('Cumulative hazard')
            text_title = str('Beta Distribution\n' + ' Cumulative Hazard Function ' + '\n' + self.param_title)
            plt.title(text_title)
            plt.subplots_adjust(top=0.87)
            return chf

    def quantile(self, q):
        '''
        Quantile calculator

        :param q: quantile to be calculated
        :return: the probability (area under the curve) that a random variable from the distribution is < q
        '''
        if type(q) == int or type(q) == float:
            if q < 0 or q > 1:
                raise ValueError('Quantile must be between 0 and 1')
        elif type(q) == np.ndarray or type(q) == list:
            if min(q) < 0 or max(q) > 1:
                raise ValueError('Quantile must be between 0 and 1')
        else:
            raise ValueError('Quantile must be of type int, float, list, array')
        return ss.beta.ppf(q, self.alpha, self.beta, 0, 1)

    def inverse_SF(self, q):
        '''
        Inverse Survival function calculator

        :param q: quantile to be calculated
        :return: the inverse of the survival function at q
        '''
        if type(q) == int or type(q) == float:
            if q < 0 or q > 1:
                raise ValueError('Quantile must be between 0 and 1')
        elif type(q) == np.ndarray or type(q) == list:
            if min(q) < 0 or max(q) > 1:
                raise ValueError('Quantile must be between 0 and 1')
        else:
            raise ValueError('Quantile must be of type int, float, list, array')
        return ss.beta.isf(q, self.alpha, self.beta, 0, 1)

    def mean_residual_life(self, t):
        '''
        Mean Residual Life calculator

        :param t: time at which MRL is to be evaluated
        :return: MRL
        '''
        R = lambda x: ss.beta.sf(x, self.alpha, self.beta, 0, 1)
        integral_R, error = integrate.quad(R, t, np.inf)
        MRL = integral_R / R(t)
        return MRL

    def stats(self):
        print('Descriptive statistics for Beta distribution with alpha =', self.alpha, 'and beta =', self.beta)
        print('Mean = ', self.mean)
        print('Median =', self.median)
        print('Mode =', self.mode)
        print('5th quantile =', self.b5)
        print('95th quantile =', self.b95)
        print('Standard deviation =', self.standard_deviation)
        print('Variance =', self.variance)
        print('Skewness =', self.skewness)
        print('Excess kurtosis =', self.excess_kurtosis)

    def random_samples(self, number_of_samples, seed=None):
        '''
        random_samples
        Draws random samples from the probability distribution

        :param number_of_samples: the number of samples to be drawn
        :param seed: the random seed. Default is None
        :return: the random samples
        '''
        if type(number_of_samples) != int or number_of_samples < 1:
            raise ValueError('number_of_samples must be an integer greater than 1')
        if seed is not None:
            np.random.seed(seed)
        RVS = ss.beta.rvs(self.alpha, self.beta, 0, 1, size=number_of_samples)
        return RVS