So we’ve decided on implementing boids in QGIS. We’ll need to establish how our boids are going to function and what the physical constraints of our boids are going to be. At minimum we need a Python class describing our boid objects, how to update them, and how to trigger their behaviours. We’ll also add some reasonable physical constraints here. This is part 3 of my series on implementing boids in QGIS. For part 1 and the table of contents, click here. For part 2 click here, for part 4 click here.
Dependencies and class layout
Our model is spatially explicit; contains vectors for position, velocity, and acceleration; and makes extensive use of linear algebra. We’ll simplify these operations by using NumPy. What we’ll want to start is a class, which we will creatively call Boid. Start out by creating a new Python script with an appropriate name (boid.py for example) in your favourite text editor:
import numpy as np class Boid(): # . . .
Let’s also briefly talk about units. We are using QGIS directly, so distance units are going to be map units. This will be metres if you use a sane projection. If you are one of those weirdos who insists on using geographic coordinate systems (*cough* marine scientists), you will want to use a projection. There is probably a mercator you can use.
I’m going to canonically declare that our time step is 1 second. This simplifies our units to metres per second for velocity and metres per second per second for acceleration. If you need help visualizing what a metre per second is, it is equal to 3.6 km/h, 2.24(ish) mph, or 1.94(ish) kts. You can adjust the time step as necessary just keep in mind that you will need to modify your units accordingly.
Constraints
We might as well start by adding some physical constraints to our constructor. Put a method called __init__ into your class, and we will add some variables to constrain our animal. Don’t worry about parameters for our constructor just yet we’ll get to that.
import numpy as np class Boid(): def __init__(self): # Physical constraints # . . .
Now is a good time to think about what our animal’s limitations are. How fast can it go? How fast can it accelerate? How far can it see? How close can it be to a neighbour before it gets nervous? These are important and incredibly species and context-specific parameters. A short finned mako can travel at highway speeds, where a deep sea angler fish might spend days not moving under its own power at all. Humpback whales might be able to tell the distance to a conspecific half the world away, but a few centimetres might be too much for a larval fish in a turbid wetland. A pacific sardine might be comfortable 50 cm away from its neighbours, but atlantic bluefin tuna would be a blob of densely packed flesh goo at that spacing. I’m going to opt for our fish species to be small and slow here, but feel free to adjust the parameters:
import numpy as np class Boid(): def __init__(self): 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 conditions
While we’re here we’ll also want some initial conditions. Let’s let the calling script pick our start position and pass some constraints on the study area. We’ll deal with the initial position here, but the study area constraints later. We’re also going to give our boid a name here so that we can track it in QGIS:
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 # Boid's name self.boidID = np.random.random()
Movement
Let’s also give our boid it’s velocity and acceleration vectors. We’ll initialize these to random numbers to start:
import numpy as np class Boid(): def __init__(self, x, y, minX, minY, maxX, maxY): . . . # Initial velocity and acceleration self.velocity = (np.random.rand(2) - 0.5) * self.maxSwimSpeed self.delta = (np.random.rand(2) - 0.5) * self.maxDelta
In order to move we’ll have to make an update method. Since we are operating in discrete time, movement is simple. Each time step we will add the current velocity to the current position to get a new position, and add the current acceleration to the current velocity to get a new velocity. We’ll update the acceleration using our behavioural methods, which we will cover in a future post. For now, we’ll set acceleration to 0 at the end of the method:
Pₙ₊₁ = Pₙ + Vₙ
Vₙ₊₁ = Vₙ + ΔVₙ
ΔVₙ₊₁ = 0
In Python code that looks like:
. . . class Boid(): . . . def update(self): self.position += self.velocity self.velocity += self.delta self.delta = np.zeros(2)
Behaviour
We need a place to call all of our behaviours. These will be used to update our acceleration to achieve our boids’ behavioural goals. We saw from the testing script that we need to take any external information in this method. For now that will be the list of other boids, so we’ll take that as an argument. Let’s add a behaviour stub but not have it do anything yet:
. . . class Boid(): . . . def behave(self, boids): # STUB: add code later self.delta = np.zeros(2); # Replace with real code
Conclusion
Well, that’s enough for one post. We now have a model which, if run, will produce a fish that starts off moving in a random direction, adjusts it course once and then continues in the resulting direction forever. We’re making progress! In my next post we’ll cover behaviours, the juicy part!
To recap: this is what you should have so far:
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 behave(self, boids): # STUB: add code later self.delta = np.zeros(2) # Replace with real code