'''
Created on Apr 2, 2015
First Sprint: 4-2-15 to 4-15-15
@author: Brett Paufler
Copyright Brett Paufler (c) 2015
All Rights Reserved


NOTE: If this is pulled from my website, it's saved as a .txt file, because...
    Well, it's not worth dealing with the headaches of loading a .py to my server.
    Change it to a .py file in notepad++ and it should work.
    Or copy paste to your favorite IDE:
        that's what I would do,
        whenever I find interesting code on the web...


Creates a Pixalated Gif animation of Boids in 'Flight'
    run_multi_boids_switchboard()
        (located at bottom of file) is the intended control function

    
class Boid
    feeds to class Flock
        feeds to class Create_Image
            which runs from run_multi_boids_switchboard()
            
    (or in reverse)
    
        run_multi_boids_switchboard()
            loads class Create_Image
                which loads class Flock
                    which Loads class Boid
                        to make a GIF image file
                
            
    # # #    SO, IF SEEKING UNDERSTANDING,      # # #
    # # #        START AT THE BOTTOM            # # #
    # # #            AND WORK YOUR WAY UP       # # #
    

Most external calls should be reasonable self explanatory, but...
    
    numpy arrays are used through for ease of vector math
        b1.pos = b2.pos - b3.pos, being essentially equal to
        np.array([5.0, 3.0]) = np.array([10.0, 10.0]) - np.array([5.0, 7.0])
    
    np.linalg.norm(a) returns scalar length of a vector
        so, np.linalg.norm(a-b) is the distance between the two points
            (using the two points as a coordinate system)
        whereas, a-b, returns a vector:
            the vector that when added to a will get one to b
            
    The following (in some degree of psuedo-code) creates the gif
        with imgList being a valid numpy image array, 
            clip = mpy.ImageSequenceClip(imgList, fps=5)
            clip.write_gif('saveGifAs.gif')   


Craig Reynold's: Boid (The Algorithm)
    Rule #1: Boids Seek Each Other (Boids flock towards their center of gravity)
    Rule #2: Boids Seek Same Heading (Boids tend to match velocity of nearby Boids)
    Rule #3: Boids Avoid Collision (Boids tend to fly away from nearby Boids)
        The exact implementation is a thing of art and esthetic beauty,
            meaning it resides in the eye of the beholder
            or the hand of the coder
            
Beyond Boids (tricking it out):
    FLOOR: there is a ground, beyond which, boids may not fall (they land, instead)
    GOAL: arbitrary positional goals (like Rule #1, but centered in space)
        NOT IMPLEMENTED: but this could be another flock of birds (Hawks & Doves)
    WIND: arbitrary vector inputs (like Rule #2, but geographic in nature)
        Once again, this is NOT IMPLEMENTED

Limitations of Implementation:
    limited to 2D
    Boids represented as dot-pixels, only

TODO:
    As noted above:
        WIND
        HAWKS & DOVES
        TOO_DAMN_CLOSE: (if boid_one.pos == boid_two.pos and vel == vel,
            boids will never separate (random, veer off, to implement)
            
I am going on vacation and taking the summer off,
    so any of that will have to await my return,
        and continued interest at some later date...
        
Brett Paufler, 4-18-15
Though in some sense the following was coded from the top down,
I have a hunch it may be easier to understand from the bottom up...
'''

import math
import copy
import random
import numpy as np
import moviepy.editor as mpy


class Boid():
    '''A Boid instance (being very much like a gameplay object, I would imagine)
    '''
    def __init__(self, pos=[0.0,0.0]):
        '''pos = np.array, current position
        vel = np.array, current velocity
        delta = np.array, current proposed change to velocity
        floor = if 'floor' in mode and boid falls to bottom of image,
            boid 'lands' and stays motionless for a random amount of time
        
            Boids are called from Flock and the passed value for pos
                is the center of the image (flock.size/2) by default
        '''
        self.pos   = np.array(pos)
        self.vel   = np.array([0.0, 0.0])
        self.delta = np.array([0.0, 0.0])
        self.floor = 0
    
    
    def __repr__(self):
        '''Has code been adequately documented if the __repr__ doesn't contain a doc string?
        Yes, my friends!  These are matters of coding-importance!!!
        '''
        return "{Boid Instance: posx:%.2f, posy:%.2f, velx:%.2f, vely:%.2f}" % (
                              self.pos[0], self.pos[1], self.vel[0], self.vel[1])    
    
    
    def distance(self,b2):
        '''scalar distance between two boids, self and b2
        '''
        dis = np.linalg.norm(self.pos - b2.pos)
        if dis < 0.1: #avoids divide by zero problems elsewhere
            dis = 0.1
        return dis

    
    def add_to_delta(self, vector, max_val):
        '''increments delta in direction of vector up to max_val
        '''
        if np.linalg.norm(vector) > max_val:
            vector = vector / np.linalg.norm(vector) * max_val
        self.delta = self.delta + vector

    
    def norm_delta(self, max_val=1.0):
        '''trims delta to 1.0 or passed value,
        the passed value is typically flock.max_delta
        '''
        if np.linalg.norm(self.delta) > max_val:
            self.delta = self.delta / np.linalg.norm(self.delta) * max_val
    
    
    def delta_to_vel(self, max_val):
        '''applies delta to vel, clears delta, and reduces vel to max_val.
        max_val is typically the passed value of flock.max_vel (max_velocity)
        '''
        self.vel += self.delta
        self.delta = np.array([0.0,0.0])
    
    
    def floor_decrement(self):
        '''counts down the floor marker
        and voids out all other motion in the interim
        
            'floor' is a mode option that sets a lower bound on boid movement
        '''
        if self.floor > 0:
            self.floor -= 1
            self.delta = np.array([0.0,0.0])
            self.vel = np.array([0.0,0.0])
    

class Flock():
    '''A Flock contains a group of Boids and controls the Motion Logic therewith
    '''
    def __init__(self, size, number=50, mode=['pos_cross' ],
                 goal=[], anti_goal=[], max_vel=5.0, max_acc=0.25):
        '''Flock variables are typically passed in from a Create_Image instance
        via the run_multi_boids_switchboard() function.
        
        number: number of boids in this flock
        
        mode: a list of special conditions
            most control structures are of the form
                if 'this_string' in flock.mode:
                    then do something
            keyword_strings (starting positions):
                'pos_rand': boids are given random positions
                'pos_cross': boids align to create a centered cross
                'pos_ver_line': boids form an evenly spaced vertical line
                'pos_hor_line': boids form an evenly spaced horizontal line
                'pos_checker': boids form a grid pattern (left over in the center)
            keyword strings (starting velocities):
                vel_rand: starting velocity is random from -max to +max
                    vel_rand_one: as above limted to -1.0 to 1.0
                vel_up, vel_down, vel_right, vel_left: vel=flock.max_vel in indicated direction
                    vel_up_one, vel_down_one, vel_right_one, vel_left_one: vel at one, not max
            keyword strings (special options)':
                'floor': boids cannot progress beyond bottom of image size (they land)
                'wind': TODO: probably will be a fluctuating random addition to pos
            ex: mode = ['pos_cross', 'floor', 'vel_rand']
            ex: mode = ['pos_checker', 'vel_up', 'vel_left']
        goal: all boids in flock are attracted to this locale (default is [], none):
            ex: goal =[np.array([25,25])]
            ex: goal =[np.array([75,75]), np.array([25,75]), np.array([75,25])] 
        anti_goal = all boids in flock are repelled from this locale:
            format is the same as per goal
            ex: anti_goal = [np.array([50,50])]
        max_vel = is the maximum speed of any one boid relative to the static background
        max_acc = is the maximum change in velocity/turn
        max_delta = is derived from max_vel and max_acc
            ex: if max_vel=5.0, max_acc=0.1, max_delta=0.5,
                so, vel may shift by 0.5(max)/turnv
        
        boids: is a list of boid objects, self.number long
        '''        
        self.size = np.array(size)
        self.number = number
        self.mode = mode
        self.goal = goal
        self.anti_goal = anti_goal 
        self.max_vel = max_vel
        self.max_acc = max_acc
        self.max_delta = self.max_vel * self.max_acc
        self.boids = [Boid(pos=self.size/2.0) for _ in range(self.number)]
        print self
        
    def __repr__(self):
        '''Messy formatting, but it works.  Feel free to clean up for me...
        '''
        a = "\nFLOCK\n"
        b = "\tNumber: %s\n" % self.number
        c = "\tmax_vel: %.2f,\t max_acc: %.2f, \t max_delta: %.2f \n" % (self.max_vel, self.max_acc, self.max_delta)
        d = '\tgaol: %s\n' % self.goal
        e = '\tanti_gaol: %s\n' % self.anti_goal
        f = '\tmode: %s\n\n' % self.mode
        return a + b + c +d + e + f
    

    def start_pos(self):
        '''changes the starting boid.pos (position) from np.array([self.size/2.0)]
            (the center) to:
        
        if key_word in self.mode (so, may be able to apply more than one):
            'pos_rand': random over self.size
            'pos_cross': centered cross (half vert line, half hor line)
            'pos_ver_line': vertical line, centered horizontally
            'pos_hor_line': horizon line, centered vertically
            'pos_checker': a grid layout
        '''
        if 'pos_rand' in self.mode:
            for b in self.boids:
                for d in range(len(self.size)):
                    b.pos[d] = random.random() * self.size[d]
        elif 'pos_cross' in self.mode:
            hN = int(self.number)/2
            vN = self.number - hN           
            hor = list(np.linspace(0, self.size[0],
                       hN, dtype=float)) + [self.size[0]/2.0]*vN
            ver = [self.size[1]/2.0]*hN +  list(np.linspace(0,
                   self.size[1], vN, dtype=float))
            for b,h,v in zip(self.boids,hor,ver):
                b.pos = np.array([h, v])
        elif 'pos_ver_line' in self.mode:
            ver = list(np.linspace(0, self.size[1], self.number, dtype=float))
            for b,v in zip(self.boids,ver):
                b.pos[0] = self.size[0]/2.0
                b.pos[1] = v     
        elif 'pos_hor_line' in self.mode:
            hor = list(np.linspace(0, self.size[0], self.number, dtype=float))
            for b,h in zip(self.boids,hor):
                b.pos[0] = h
                b.pos[1] = self.size[1]/2.0
        elif 'pos_checker' in self.mode:
            s = int(math.floor(math.sqrt(self.number)))
            x = self.size[0] / float(s)
            y = self.size[1] / float(s)
            hor = list(np.linspace(0+x, self.size[0]-x, s, dtype=float))
            ver = list(np.linspace(0+y, self.size[1]-y, s, dtype=float))
            c = 0
            for h in hor:
                for v in ver:
                    self.boids[c].pos[0] = h
                    self.boids[c].pos[1] = v
                    c += 1    

        
    def start_vel(self):
        '''changes boid.vel (velocity) staring value from np.array([0.0, 0.0]) to:
        
            if keyword_string in Boid.mode (so, can fire more than one):
                vel_rand: starting velocity is random from -max to +max
                    vel_rand_one: as above limted to -1.0 to 1.0
                vel_up, vel_down, vel_right, vel_left: vel=flock.max_vel in indicated direction
                    vel_up_one, vel_down_one, vel_right_one, vel_left_one: vel at one, not max
        '''
        for b in self.boids: 
            if 'vel_rand' in self.mode:
                b.vel[0] += (random.random()*2.0 - 1.0) * self.max_vel
                b.vel[1] += (random.random()*2.0 - 1.0) * self.max_vel
            if 'vel_rand_one' in self.mode:
                b.vel[0] += (random.random()*2.0 - 1.0)
                b.vel[1] += (random.random()*2.0 - 1.0)
            if 'vel_down' in self.mode:
                b.vel[1] += self.max_vel
            if 'vel_down_one' in self.mode:
                b.vel[1] += 1
            if 'vel_up' in self.mode:
                b.vel[1] += - self.max_vel
            if 'vel_up_one' in self.mode:
                b.vel[1] += -1
            if 'vel_right' in self.mode:
                b.vel[0] += self.max_vel
            if 'vel_right_one' in self.mode:
                b.vel[0] += 1
            if 'vel_left' in self.mode:
                b.vel[0] += - self.max_vel
            if 'vel_left_one' in self.mode:
                b.vel[0] += -1


    def vel_flock_pos(self):
        '''distance weighted increment of self.delta,
        based on POSITION of other boids in flock
        (in essence, each boid tries to move toward the center of nearby boids)
        
        Rule #1 Implementation
        vel_flock_pos = + vector / distance
        '''
        for b1 in self.boids:
            ave = np.array([0.0,0.0])
            for b2 in (b2 for b2 in self.boids if b2 != b1):
                ave -= (b1.pos - b2.pos) / b1.distance(b2)
            b1.add_to_delta(ave, self.max_delta) 
        
    
    def vel_flock_vel(self):
        '''distance weighted increment of self.delta,
        based on VELOCITY of other boids in flock
            (in essense, each boid tries to mimic the behavior of nearby boids)
        
        Rule #2 Implementation
        vel_flock_vel = + vector / distance**2
            Note: pow(x,2)
        '''
        for b1 in self.boids:
            ave = np.array([0.0,0.0])
            for b2 in (b2 for b2 in self.boids if b2 != b1):
                ave -= b2.vel / pow(b1.distance(b2),2)
            b1.add_to_delta(ave, self.max_delta) 


    def vel_flock_self_avoidance(self):
        '''distance weighted decrement of self.delta,
        based on POSITION of other boids in flock
            (in essence, each boid tries to avoid nearby boids)
        
        Rule #3 Implementation
        vel_flock_self_avoidance = - vector / distance**3
            Note: the minus
            Note: pow(x,3)
        '''
        ave = np.array([0.0,0.0])
        for b1 in self.boids:
            for b2 in (b2 for b2 in self.boids if b2 != b1):
                ave += (b1.pos - b2.pos) / pow(b1.distance(b2), 3)
            b1.add_to_delta(ave, self.max_delta) 


    def vel_flock_too_damn_close(self):
        '''if two boids line up on both pos and vel (pos1=pos2 & vel1=vel2),
        the boids will visually converge and flow together (disappear into one)
        In order to force the two boids apart, need some sort of function here
        '''
        pass
        #TODO: NOT IMPLEMENTED
        #NEED TO ADD TOO DAMN CLOSE
        #Thinking something like
        #self.add_to_delta(random in range (-self.max_delta to + self.max_delta)
        
    
    def vel_goal(self):
        '''a fixed target version of vel_flock_pos
        
        boids move toward goal
        boids move away from anti_goal
        
        TODO: HAWK and DOVE implementation,
            flocks which have other flocks as their goal
        '''
        for b in self.boids:
            ave = np.array([0.0,0.0])
            for g in self.goal:
                g = Boid(pos=g)
                ave -= (b.pos - g.pos) / b.distance(g)
            for g in self.anti_goal:
                g = Boid(pos=g)
                ave += (b.pos - g.pos) / b.distance(g)
            b.add_to_delta(ave, self.max_delta)
            
    
    def apply_deltas(self):
        '''delta reduced to max_val, delta added to vel, delta reset to zero
        '''
        for b in self.boids:
            b.norm_delta(max_val=self.max_delta)
            b.delta_to_vel(max_val=self.max_vel)


    def move(self):
        '''moves a turn, increments the flock state by one turn
        '''
        #Base Craig Reynold's Boid (Delta Increment)
        self.vel_flock_pos()
        self.vel_flock_vel()
        self.vel_flock_self_avoidance()
    
        #Advanced Options: Goals and Floor (Delta Increment)
        if self.goal or self.anti_goal:
            self.vel_goal()
        if 'floor' in self.mode:
            for b in self.boids:
                b.floor_decrement()
    
        #vel+=delta, pos+=vel, delta=0
        self.apply_deltas()     
        for b in self.boids:
            b.pos += b.vel   

        #The FLoor Check
        #Yeah, this could stand to be refactored.
        #But have I mentioned my upcoming vacation, yet?
        if 'floor' in self.mode:
            for b in self.boids:
                if b.pos[1] > self.size[1] - 1:
                    b.pos[1] = self.size[1] - 1
                    b.vel = np.array([0.0, 0.0])
                    b.floor = random.randint(1,10)
    
    
    def flock_to_img(self):
        '''returns an img array,
        Boids are signified by a value of 255,
            which will be converted to an image dot by mpy write_gif
        Boids that are 'out of bounds' boids are ignored
        
        Note: In my naming convention:
            img: stands for an img like array
            image: stands for an actual image file
            So, clearly, we're returning an img array
        '''
        img = np.zeros((self.size[1], self.size[0]), dtype=int)
        for b in self.boids:
            x = math.floor(b.pos[0])
            y = math.floor(b.pos[1])
            
            if (x>= 0) & (x<self.size[0]) & (y>= 0) & (y<self.size[1]):
                img[y,x] = 255 
        return img


class Create_Image():
    '''Encapsulates the Whole Shebang and outputs one or more image files,
    depending upon the number of flocks
    
    Create_Image > Flock > Boid
        Create_Image will call Flock, which will call Boid 
            run_multi_boids_switchboard() is the hook
        
        Oh, and just as a bye-the-bye, as a work flow,
            I got an image to screen
            Got it working sort of right (but mostly wrong)
            Maybe created a Boid class
            Got the whole shebang working better (still some flaws)
            Reformatted to include a Flock Class (a major endeavor)
            Got the Flock class tricked out
            Reformatted to include a Create_Image class,
                by which time I was pretty much done.
            Refactored.
            Killed dead code.
            Added almost all of the comments (based on previous notes)
            Reread and proofread.
            Call it forty hours, maybe more, I don't keep track.
            After that, presumably, I'll start on the webpage.
            So, maybe, sixty hours for the project is closer to the truth...
    '''

    def __init__(self, turns=1, blank=0, size=np.array([100,100]),
                 flocks=[], sN="boid_test_gif_needs_name.gif"):
        '''An Object to hold the values needed to make an intricate gif image
        
        turns = length of gif (including blank & first page),
            so, one gives a single shot of the starting position
        blank = number of leading blank screens with which to start the gif image
            helpful for making it clear when the loop restarts
        size = (width x height) output image size
        flock = [], a list of dictionaries containing Flock(argValues)
            one dictionary for each flock
                see, run_multi_boids_switchboard() for more info on this
        sN = is my naming convention for saveName
            I do a lot of image work.
            It's convenient to use consistent variable names,
                from project to project.
        '''
        self.turns = turns
        self.blank = blank
        self.size = np.array(size)
        self.flocks = flocks #see: run_multi_boids_switchboard()
        self.sN = sN
        print self


    def __repr__(self):
        '''In the words of the Daleks:
        
            'Must Comment Code!'
            'Must Comment Code!!'
            'Must Comment Code!!!'
        '''
        a = '\nImage_Scene:\n'
        b = '\tTurns: %d, \tBlank: %d, \tSize: %s\n' % (self.turns, self.blank, self.size)
        c = '\tFlocks: %d' % len(self.flocks)
        return a + b + c
    
 
    def run(self, flockDict=[{'mode':['pos_checker']}]):
        '''Makes a GIF!!!
            The only meaningful method in the Create_Image class.

            See: run_multi_boids_switchboard() for more information on flockDict
                Note: I keep on saying this, so there must be a reason...
        '''
        #Holding array for the img lists
        #When full, this will sort of look like:
        #imgList = [[img,img,img,...], [img,img,img,...],...]
        imgList = []
        
        #Runs Each Flock Seperately
        #So, in order to implement Hawk & Dove,
        #This will need to be refactored
        #Or more accurately, torn down and rebuilt completely
        for flock in self.flocks:
            flock = Flock(size=self.size, **flock)
            if flock.mode:
                flock.start_pos()
                flock.start_vel()
            
            #Each layer (one per flock) will turn into a seperate GIF and/or color layer
            imgLayer = []
            
            #Adds zero arrays, which come out as black screens 
            for _ in range(self.blank):
                imgLayer.append(np.zeros((self.size[1], self.size[0]), dtype=int))
                
            #An img of the starting position ('pos_cross', etc.) added to the layer
            imgLayer.append(flock.flock_to_img())
            
            #A Turn (move) is made and appended to the layer
            for n in range(self.turns - self.blank - 1):
                print "Boids: Turn %d" % n
                flock.move()
                imgLayer.append(flock.flock_to_img())
                
            imgList.append(imgLayer)  
        
        #OUTPUT THE GIF
        #There are only two tested/value lenghths for flock (one or three)
        #Others might work, but these are the only ones tested for
        
        #For the three layer condition, the first three layers are zipped together
        if len(imgList) >= 3:
            imgList.append([np.dstack((a,b,c)) for a,b,c in zip(imgList[0], imgList[1], imgList[2])])
        
        #And each layer is outputed as a GIF
        #The first three layers seperately in black and white
        #The forth in a single RGB GIF (Red, Green, Blue layers in order)
        for i, img in enumerate(imgList):
            sN = self.sN[:-4] + '_%d' % i + self.sN[-4:]
            print sN
            clip = mpy.ImageSequenceClip(img, fps=5)
            clip.write_gif(sN)    


def run_multi_boids_switchboard():
    '''Runs the works, outputing images as appropriate
    
    If you just want to make a picture and don't really care about the code,
        this is all you need to understand
        
    Code is tested for one or three flocks
        One Flock: Yields a single black and white gif
        Three Flocks: Yields three black and white gifs
                      And a color composite overlapping the three
    '''
    
    #Image Wide Variables, Reference the Create_Image class if these aren't clear
    image_variables = {'turns': 250, #reduce this to like 5 for debugging
                       'blank': 2,
                       'size': np.array([500,100]),
                       'flocks': [],
                       'sN':"07_boids_colorized.gif"
                       }
    img = Create_Image(**image_variables)
    
    #Variables for a flock, this can be copied wholesale and modified for each flock
    #Or amended piecemeal, as I do below
    #The commented out values to the right are values recently used
    #I work in my code (inside my IDE), so THIS IS my interface
    sample_flock = {'number': 50, 
                    'mode': ['pos_checker'], ##, 'floor', 'vel_down', ],
                    'max_vel': 5.0,
                    'max_acc': 0.25,
                    'goal':[], #[np.array([0,50])], #[np.array([25,25]), np.array([75,75]), np.array([25,75]), np.array([75,25])] 
                    'anti_goal': [], #[np.array([0,50])],                 
                    }
   
    #No copy.deepcopy and you might find all the dictionaries look alike
    f1 = copy.deepcopy(sample_flock)
    f1['mode'] = ['pos_cross', 'floor', 'vel_right']
    f1['goal'] = [np.array([100,75])]
    f1['number'] = 25
    
    f2 = copy.deepcopy(sample_flock)
    f2['mode'] = ['pos_checker', 'floor', 'vel_up_one']
    f2['goal'] = [np.array([250,25])]
    f2['number'] = 100
    
    f3 = copy.deepcopy(sample_flock)
    f3['mode'] = ['pos_rand', 'floor', 'vel_left']
    f3['goal'] = [np.array([400,75])]
    f3['number'] = 50
    
    #Lump together, save to the working Create_Image instance, and run
    flocks = [f1,f2,f3]
    img.flocks = flocks
    img.run()


if __name__ == '__main__':
    '''Actually, my name = 'Brett Paufler',
    And I hope you've enjoyed.
    
    Copyright Brett Paufler 4-18-15
    '''
    print 'BOIDS: RUNNING AS MAIN'
    #Obviously, this is the hook that runs it all
    run_multi_boids_switchboard() 
    print "Multi Boid SwitchBoard: RUN OVER"


#TODO - PERHAPS ADD WIND
#TODO - Hawk and Dove, Interaction Between Flocks

'''
Copyright Brett Paufler 4-18-15 All Rights Reserved
See Terms of Service for Complete Details at www.paufler.net
Brett@Paufler.net
'''