Boids in QGIS part 4:
Primary behaviours

In our last exercise we established how to initialize our model and get the fish to move. A single direction forever is not particularly interesting, however. We need to give our boids some behaviours. In Reynolds’s original description of the model these include only alignment, cohesion, and avoidance. We’ll accomplish these behaviours by computing the acceleration necessary to achieve them and then adding the resulting accelerations together to calculate a new acceleration. This is part 4 of my series on implementing Boids in QGIS. For the table of contents click here. For part 3 click here, for part 5 click here.

Our boids interact with each other through three bevhaviours that facilitate schooling: alignment, cohesion, and separation (avoidance). These behaviours are constrained by the boids ability to see others as well as physical constraints on what the boid can do. We’ll start by creating the methods necessary for our behaviours to work, including align, cohese (is that a verb? it is now!), avoid, and behave. In my next post we’ll call the three behaviour methods and sum their results in the behave method, which we created a stub for earlier. All of these methods will need to take a list of boids as an argument, since we will call behave from the calling script, we’ll pass the list to our object there. This should be the result:

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

    def update(self):
    . . .

    def align(self, boids):
        dDelta = np.zeros(2)
        # STUB: add code here
        return dDelta
    
    def cohese(self, boids):
        dDelta = np.zeros(2)
        # Stub: add code here
        return dDelta

    def avoid(self, boids):
        dDelta = np.zeros(2)
        # Stub: add code here
        return dDelta

    def behave(self, boids):
        # Stub: add code here
        self.delta = np.zeros(2) # Update with real code

It’s looking good so far. Let’s add some behaviours!

Alignment

Each fish wants to travel in the same direction and at the same speed as every fish it can see. We’ll accomplish this by averaging the velocity of each fish within the fish’s sight distance and calculating an acceleration to match the speed and direction:

ΔVₐ = Σ(0–n)Vₓ ÷ n – V

We’ll add the following code to our align method:

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

Cohesion

Each fish would like to reach the centre of any mass of fish that it can see. We’ll accomplish this determining our target point to be the average location of all visible fish:

Pₜ = Σ(0–n)Pₓ ÷ n

We’ll the find the velocity vector necessary to move to that point in the next turn:

Vₜ = Pₜ – Pₛ

We’ll normalize the velocity vector to get the direction to accelerate in to steer toward the middle of the group:

ΔVₑ = Vₜ ÷ |Vₜ|₂

This gets us a complete equation to accelerate toward the middle:

ΔVₑ = (Σ(0–n)Pₓ ÷ n – Pₛ) ÷ |Σ(0–n)Pₓ ÷ n – Pₛ|₂

We’ll add the following code to our cohese method:

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

Avoidance (Spacing)

Our fish would like to avoid colliding with or crowding other nearby fish. This ends up being the most complicated behaviour we’ll need to implement here, but it’s not hard. Each turn we will determine the position of each fish inside of the avoidance distance and calculate the velocity necessary to steer directly away from each position. The steering vector is simply the average of these the directions away from the intruding fish, weighted by the inverse of their distance. For each intruding fish, we’ll calculate the velocity necessary for our fish to reach a point directly opposite the intruding fish in the next turn, normalize the velocity vector to get the direction away from the intruding fish, multiply this direction vector by the inverse of distance, and then average the resulting directions. Finally we will re-normalize the resulting vector to get a the unit direction.

The velocity necessary to reach a target position in the next turn is simply the target position minus the current position. We want to move away from the target position though, so we calculate the opposite direction:

Vₛ = Pₛ – Pₜ

The direction to avoid the collision or intrusion is simply the normalized velocity:

Aₛ = Vₛ ÷ |Vₛ|₂

The target direction is simply the weighted average of the velocities calculated to avoid each intruding fish:

Aₜ = Σ(0–n)Aₛₓ ÷ (nDₓ)

The direction we’ll accelerate in to steer away from the collision is the normalized velocity:

ΔVₛ = Aₜ ÷ |Aₜ|₂

Giving us a total equation of:

ΔVₛ = (Σ(0–n)(Pₛ – Pₜₓ) ÷ (nDₓ)) ÷ |Σ(0–n)(Pₛ – Pₜₓ) ÷ (nDₜₓ)|₂

We’ll add the following code to our avoid method:

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 to get 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

Conclusion

Well, that was quite a lot! We now have all of our behaviours coded into the boids object but it still doesn’t do anything. In my next post we’ll cover the behave method and behavioural weighting. For now our object 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 
 
        # 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): 
        # STUB: add code later 
        self.delta = np.zeros(2) # Replace with real code