Boids in QGIS part 5:
Putting it together

In our previous exercise we covered the mathematics and code behind implementing each of the three primary boid behaviours. Our model still doesn’t do anything though! In this exercise we will add a function to call the behaviours, weight their importance, and calculate a new acceleration vector. We’ll also (finally) add some constraints on the speed and acceleration of our boids. This is part 5 of my series on implementing boids in QGIS. For the table of contents, click here. For part 4 click here, or for part 6 click here.

We’ve implemented the math behind our behaviours but our boid still doesn’t exhibit any behaviours of its own. Let’s fix that. We’re going to add code to our behave method which will call each of the behaviours, weight them, and sum them into a new acceleration vector. From our previous exercise our code should look like this:

import numpy as np
class Boid():
    __init__(self, x, y, minX, minY, maxX, maxY):
        . . .

    update(self):
        . . .

    align(self, boids):
        . . .
    
    cohese(self, boids):
        . . .

    avoid(self, boids):
        . . .

    behave(self, boids):
        # STUB: add code here
        self.delta = np.zeros(2)

Our behave method is going to simply add together the accelerations associated with the three behaviours, after multiplying them by three constants:

ΔV(n+1) = ΔVₐₙWₐ + ΔVₑₙWₑ + ΔVₛₙWₛ

The constants Wₐ, Wₑ, and Wₛ are weights associated with the importance of each of the three behaviours: alignment, cohesion, and avoidance (separation), respectively. These are context-specific and we will introduce them in the __init__ function. Our behave function should look like this:

behave(self, boids):
    alignment = self.align(boids) * self.aWeight
    cohesion = self.cohese(boids) * self.cWeight
    avoidance = self.avoid(boids) * self.sWeight
    self.delta = self.delta + alignment + cohesion + avoidance

Updating __init__

We will also need to add out weights to the __init__ method. If you have a keen mathematical eye you may have noticed in the previous post that our align function returns exactly the acceleration necessary to match the target velocity in the next turn, but cohese and avoid both return normalized vectors representing only a direction to accelerate in. Wₐ should be a number between 0 and 1 and represents the fraction of the acceleration the animal will make on each turn. Wₛ and Wₑ should be numbers between 0 and self.maxDelta and represent the actual magnitude of the acceleration caused by these behaviours. In all cases a weight of 0 will make the animal not exhibit the specific behaviour. Higher weights will affect the resulting acceleration more than lower. This is what we’ll add to the __init__ method. I am weighting cohesion and separation equally and giving aWeight a value of 0.5, so it will take two time steps to match a velocity:

def __init__(self, x, y, minX, minY, maxX, maxY):
    . . .
    # Specify our behavioural weights
    self.aWeight = 0.5
    self.cWeight = 1
    self.sWeight = 1
    . . .

Conclusion

We now have boids! If we run this code, we will find, however, that our boids don’t obey our speed limits and at accelerate at unconstrained rates all over the world. We’ll address this problem in the next post, which you can read now if you click here. For now our code should look like this:

import numpy as np 
class Boid(): 
    def __init__(self, x, y, minX, minY, maxX, maxY): 
        # Initial conditions 
        self.position = np.array([x, y]) 

        # Physical constraints 
        self.maxSwimSpeed = 2 # Maximum speed 
        self.maxDelta = 1 # Maximum acceleration 
        self.perceptionDistance = 3 # Maximum distance to see other boids 
        self.avoidanceDistance = 0.6 # Minimum spacing for avoidance behaviour 

        # Specify our behavioural weights 
        self.aWeight = 0.5 # Alignment, should be 0 ≤ aWeight ≤ 1
        self.cWeight = 1 # Cohesion, should be 0 ≤ cWeight ≤ maxDelta
        self.sWeight = 1 # Avoidance (separation), should be 0 ≤ sWeight ≤ maxDelta
 
        # Initial velocity and acceleration 
        self.velocity = (np.random.rand(2) - 0.5) * self.maxSwimSpeed 
        self.delta = (np.random.rand(2) - 0.5) * self.maxDelta 

        # Boid's name
        self.boidID = np.random.random()

    def update(self): 
        self.position += self.velocity 
        self.velocity += self.delta 
        self.delta = np.zeros(2)

    def align(self, boids): 
        # We need a place to store our the acceleration we come up with 
        # as well as the total number of fish we are averaging across 
        dDelta = np.zeros(2) 
        totalNearby = 0
 
        # Take the total list of fish available to be nearby and calculate 
        # the distance to our fish 
        For boid in boids: 
            difference = boid.position - self.position 
            distance = np.linalg.norm(difference) 

            # If the fish is visible to our fish, add it's velocity to our 
            # target velocity, count the number of nearby fish 
            if distance > 0 and distance < self.perceptionDistance: 
                dDelta += boid.velocity 
                totalNearby += 1 

        # Divide the total velocity of each nearby fish by the number of 
        # nearby fish to get the average velocity, this is our target 
        # velocity. 
        if totalNearby > 0: 
            dDelta /= totalNearby 

            # The acceleration necessary to reach our target velocity in time 
            # step t is simply the target velocity minus our current velocity 
            dDelta -= self.velocity 

        return dDelta

    def cohese(self, boids): 
        # Create a place to store our target acceleration and total visible fish 
        dDelta = np.zeros(2) 
        totalNearby = 0 

        # Check whether any fish are visible to our current fish. Sum their position 
        # if they are visible 
        for boid in boids: 
            difference = boid.position - self.position 
            distance = np.linalg.norm(difference) 
            if distance > 0 and distance < self.perceptionDistance: 
                dDelta += boid.position 
                totalNearby += 1 

        # If any fish were visible, average their positions to find the target 
        # position. 
        if totalNearby > 0: 
            dDelta /= totalNearby 

            # Calculate the velocity necessary to reach the target position. 
            dDelta -= self.position 

            # Normalize the velocity to generate just the direction to accelerate in to 
            # reach the target position 

            magDelta = np.linalg.norm(dDelta) 
            if magDelta > 0: # Make sure we are not at the center of the group (avoid /0) 
                dDelta /= magDelta 
            else: # Do nothing if we are already at the middle. 
                dDelta = np.zeros(2) 

        return dDelta

    def avoid(self, boids): 
        # Create a place to store our target acceleration and the total fish 
        # we want to avoid colliding with. 
        dDelta = np.zeros(2) 
        totalNearby = 0 

        # Check each other fish to see if it is close enough to avoid 
        for boid in boids: 
            difference = self.position - boid.position 
            distance = np.linalg.norm(difference) 

            # Sum the weighted directions necessary to move directly away from # each nearby fish on the next turn. 
            if distance > 0 and distance < self.avoidanceDistance:
                difference /= distance # Normalize the direction 
                dDelta += difference / distance # Weight by inverse distance 
                totalNearby += 1 

        # Calculate the velocity necessary to steer away from the avoidance point 
        # on the next update (finish the weighted average) 
        if totalNearby > 0: 
            dDelta /= totalNearby 

            # Normalize the velocity away from avoidance point to get the 
            # direction necessary to accelerate in to avoid collision. 
            magDelta = np.linalg.norm(dDelta) 
            if magDelta > 0: 
                dDelta /= magDelta 
            else: 
                dDelta = np.zeros(2) 

        return dDelta

    def behave(self, boids): 
        alignment = self.align(boids) * self.aWeight 
        cohesion = self.cohese(boids) * self.cWeight 
        avoidance = self.avoid(boids) * self.sWeight 
        self.delta = self.delta: + alignment + cohesion + avoidance