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
