#----------------------------------------------------------------------------------
# Class containing methods to perform analytics on an array of numbers
# Methods are:  count(), avg(), median(), mode(), stdDev()
#               Python has built-in functions  sum(), min(), max() 
#               normalize(): takes a 1dim array, and eliminates bad/no data  
#               slice():     takes a 2dim array, and returns a row or col slice  
#               transpose(): takes a 2dim array, and transpose it x/y becomes y/x  
#----------------------------------------------------------------------------------
import math                                 #for math.pow() and sqrt()
import statistics                           #for mean(), median(), stdDev()
import sys                                  #for sys.float_info.min float

class Analytics:
#--------------------------------------------------------------------------------
# __init__: Constructor, takes an array, and performs all analytics on it.
#--------------------------------------------------------------------------------
    def __init__(self,array):

        self.__array  = array                       #store incoming array

        self.__count  = self.count()                #define instance attributes
        self.__sum    = self.sum()                  #and call their respective methods
        self.__avg    = self.avg()                  #attribute are private using __
        self.__median = self.median()
        self.__mode   = self.mode2()
        self.__min    = self.min()
        self.__max    = self.max()
        self.__range  = self.range()
        self.__stdDev = self.stdDev()

#--------------------------------------------------------------------------------
# getter methods
#--------------------------------------------------------------------------------
    def getCount(self) : return self.__count
    def getSum(self)   : return self.__sum
    def getAvg(self)   : return self.__avg              #define getter methods
    def getMedian(self): return self.__median
    def getMode(self)  : return self.__mode
    def getMin(self)   : return self.__min
    def getMax(self)   : return self.__max
    def getRange(self) : return self.__range
    def getStdDev(self): return self.__stdDev
    
#--------------------------------------------------------------------------------
# normalize: Returns a new clean array that has all the bad/no data removed 
#--------------------------------------------------------------------------------
    @staticmethod                                       #class static method
    def normalize(array):
        array2 = []                                     #create new clean array

        for col in array:                               #loop through all columns                  
            try:                                        
                num = float(col)                        #convert string to number
                array2.append(num)                      #add to new clean array
            except:
                pass                                    #do nothing (no op)   

        return array2;                              

#--------------------------------------------------------------------------------
# Analytics functions
#--------------------------------------------------------------------------------
    def count(self):   return len(self.__array)                                                  
    def sum(self):     return sum(self.__array)                                                   
    def min(self):     return min(self.__array)                                                   
    def max(self):     return max(self.__array)
    def range(self):   return max(self.__array) - min(self.__array)                                                   

    def avg(self):     return statistics.mean(self.__array) 
    def median(self):  return statistics.median(self.__array) 
    def medianL(self): return statistics.median_low(self.__array) 
    def medianH(self): return statistics.median_high(self.__array) 
#   def mode(self):    return statistics.mode(self.__array)         #only a single mode, better function below 
    def stdDev(self):  return statistics.pstdev(self.__array)       #full population std dev 
    def stdDevS(self): return statistics.stdev(self.__array)        #sample standard deviation 
                        

#--------------------------------------------------------------------------------
# median: Returns the median value of an array
#--------------------------------------------------------------------------------
    '''
    def median(self):
        array2 = sorted(self.__array)                    #sort the array                   
        
        count  = self.__count
        median = 0

        mid1 = round(count/2)                           #round .5 up 
        mid2 = mid1 -1
        if (count%2 == 1):                              #if count is odd
            median = array2[mid1]                       #median= mid point                                                               
        else:                                           #else, if even
            median = (array2[mid1] + array2[mid2]) /2;  #median= average of 2 mid points                                                               

        return median                              
    '''

#--------------------------------------------------------------------------------
# mode: Returns the mode value(s) of an array     (algorithm 1)
#       There might be more than 1 mode.  therefore return an array
#       If all values are unique (no count > 1), mode is none
#--------------------------------------------------------------------------------
    def mode(self):
        prevCol   = sys.float_info.min
        prevCount = 0
        maxOccur  = 2
        modes     = []

        array2 = sorted(self.__array)                       #sort the array                   

        for col in array2 :
            if col == prevCol :
                prevCount += 1
            else:
                if prevCount > maxOccur :
                    maxOccur = prevCount                    #save the max occurences thus far
                    modes = []                              #clean up the modes array
                if prevCount >= maxOccur :
                    modes.append(prevCol)                   #add it to the modes array
                prevCount = 1
                prevCol  = col
             
        if prevCount > maxOccur :                           #test/process the last record                   
            maxOccur = prevCount                            #save the max occurences thus far 
            modes = []                                      #clean up the modes array
        if prevCount >= maxOccur :
            modes.append(prevCol)                           #add it to the modes array

        return modes                              

#--------------------------------------------------------------------------------
# mode2: Returns the mode value(s) of an array   (algorithm 2)
#--------------------------------------------------------------------------------
    def mode2(self):
        maxOccur = 2                                       #set minimum number of occurrence
        modes    = []                                      #create an empty list
        dict     = {}                                      #create a dictionary

        for elem in self.__array:                          #iterate thru elements
            try:                                           #try:
                dict[elem] += 1                            #add 1 to the element's value
            except:                                        #if unsuccessful, element must not be in dictionary 
                dict[elem]  = 1                            #add the element to dictionary with value 1

        listOfTuples = sorted(dict.items(), key=lambda elem: elem[1], reverse=True)     #sort by highest value 

        for tuple in listOfTuples:                         #loop through the sorted list of tuples
            (elem, occur) = tuple                          #each tuple has element and value
            if occur < maxOccur:                           #if occurrence < maxOccurence
                break                                      #we are done, no need to continue
            modes.append(elem)                             #add the element to the list of modes
            maxOccur = occur                               #save the number of occurence as maxOccurence 
    
        return modes   
    
#--------------------------------------------------------------------------------
# stdDev: Returns the standard deviation of an array
#         It is a measure of the amount of variation of a set of data values
#         Low stdDev means the values are close to the average (or are tight) 
#         1. take average of array
#         2. take the difference (delta) of each element to the average
#         3. take the square of that delta
#         4. add all those square of deltas
#         5. divide the square of deltas by count of elements
#         6. take the square root of item 5.  
#--------------------------------------------------------------------------------
    '''
    def stdDev(self):
        sqDelta = 0;                                  #square of deltas      
        for col in self.__array:                      #loop through all columns                  
            sqDelta += math.pow(col - self.__avg,2);  #add to square of delta

        std_dev = math.sqrt(sqDelta/self.__count);    #square root of average(square of deltas)
        return std_dev;                              
    '''

#--------------------------------------------------------------------------------
# toString: Returns the array as well as all the analytic computations 
#--------------------------------------------------------------------------------
    def __str__(self):
        data  = "Data points: "   + str(self.__array )
        data += "\nCount......: " + str(self.__count ) 
        data += "\nSum........: " + str(self.__sum   ) 
        data += "\nAverage....: " + str(self.__avg   ) 
        data += "\nMedian.....: " + str(self.__median) 
        data += "\nMode.......: " + str(self.__mode  ) 
        data += "\nMinimum....: " + str(self.__min   ) 
        data += "\nMaximum....: " + str(self.__max   ) 
        data += "\nRange......: " + str(self.__range ) 
        data += "\nStd.Dev....: " + str(self.__stdDev) 
        return data;                              

#--------------------------------------------------------------------------------
# slice: Takes  a 2 dimensional array, slice type (row/col/all), and index
#        Return a single dimention array for that row or column or all cells
#--------------------------------------------------------------------------------
    @staticmethod                                       #class static method
    def slice(array2dim, type, idx):
        size  = 0;
        array = [];

        if (type =="row"):                              #ROW slice
            array = array2dim[idx]                          #take a row slice

        if (type =="col"):                              #COL slice
            obj    = zip(*array2dim)                        #transpose 2dim array, returns an object
            array2 = list(obj)                              #turn the object into an array
            array  = array2[idx]                            #take a slice 

        if (type =="all"):                              #ALL slice (turn a 2dim array to 1 dim)
            for row in array2dim:                           #loop through all rows                     
                for col in row:                             #loop through all columns
                    array.append(col)                       #add to new 1dim array

        return array;

#--------------------------------------------------------------------------------
# transpose: Transpose a 2 dimensional array, x/y dimension become y/x dimension
#            Return a 2 dimensional transposed array
#--------------------------------------------------------------------------------
    @staticmethod                                       #class static method
    def transpose(array2dim):

        array2 = zip(*array2dim)                        #transpose 2dim array   
        array2 = list(obj)                              #turn the object to an array
  
        return array2;

#--------------------------------------------------------------------------------