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