Defining new species
The Persephone species DSL
In order to make implementing new species as easy as possible, Persephone includes a domain-specific language (DSL) built from a collection of macros and functions.
Here is an example of what this looks like, using a hypothetical mermaid species:
@species Mermaid begin
ageofmaturity = 2
pesticidemortality = 1.0
@initialise(@habitat(@landcover() == water), pairs=true)
@phase life begin
@debug "$(animalid(animal)) is swimming happily in its pond."
@respond pesticide @kill(@trait(pesticidemortality), "poisoning")
@respond harvest @setphase(drought)
@debug "Animal: $animal"
if @trait(sex) == female && @countanimals() < 3 &&
@trait(age) >= @trait(ageofmaturity) && @landcover() == water
@reproduce()
end
end
@phase drought begin
n = sum(1 for a in @neighbours(0))
@debug "$(animalid(animal)) is experiencing drought with $n neighbour(s)."
@respond sowing @setphase(life)
end
end
The two most important macros are @species
and @phase
, followed by @initialise
, @trait
, @respond
, and @habitat
. Other macros provide convenience wrappers for common functions. (See src/nature/nature.jl
for details.)
The top-level macro is @species
. This takes two arguments: a species name and a definition block (enclosed in begin
and end
tags). At the start of the definition block, species-specific variables can be defined that should be available throughout a species' lifetime. Code in this section has access to the model
object and can thus reference the current model state. In this section, the user also has to call the @initialise
macro. This wraps the initpopulation
function, and takes a habitat descriptor (see @habitat
below) and several options to specify how the species' population should be distributed in the landscape during model initialisation.
Following this section, each species must define one or more @phase
blocks. The concept behind this is that species show different behaviours at different phases of their lifecycle. Each @phase
block defines the behaviour in one of these phases. (Technically, it defines a function that will be called daily, so long as the species' phase
variable is set to the name of this phase.) Code in this section has access to the model
object as well as an animal
object, which is the currently active animal agent. Properties of the animal
agent, regardless of whether they were defined by the user or by Persephone, can be accessed using the @trait
macro. Within a phase block, @respond
can be used to define the species' response to a FarmEvent
that affects the species' current location, while a variety of other macros provide wrappers to ecological process functions from src/nature/populations.jl
.
Another important macro is @habitat
. This defines a "habitat descriptor", i.e. a predicate function that tests whether or not a given landscape pixel is suitable for a specified purpose. Such habitat descriptors are used as arguments to various functions, for example for population initialisation or movement. The argument to @habitat
consists of a logical expression, which has access to the animal's current position (the pos
tuple variable) and the model
. Various macros are available to easily reference information about the current location, such as @landcover
or @distancetoedge
.
Implementation details
Due to a known performance problem with multi-agent models, the underlying implementation of species is rather complicated (see src/nature/nature.jl
for details.)
Rather than creating a new type/struct for each species, all Animal agents have the same type. Instead, they are differentiated by a traits
dict, which stores both species-specific parameters and run-time variables. Note that due to a redefinition of the getproperty()/setproperty!()
methods, variables from the trait dict can be accessed and modified just like normal struct fields (i.e. although phase
is defined in the dict, not the struct, animal.phase = "newphase"
works just fine - one does not have to use animal.traits["phase"] = "newphase"
.)
Under the hood, the @species
macro generates a function (with the name of the species), which in turn creates the trait dict when called. Thus, adding a new animal agent to the model involves instantiating an Animal
object, then calling the relevant species function and attaching the returned dict to the agent object.
Similarly, the @phase
macro too works by defining a new function, which is stored in the species' trait dict. These functions take an animal object and the model object as input, and define what the species does during its daily update.
Once again, @habitat
creates a function that takes model
and pos
as input and returns a boolean response. Functions that require a habitat descriptor thus take in this (anonymous) function and call it internally.
Finally, the @initialise
macro is a wrapper around initpopulation
, which (yet again) creates a function that specifies how a species' population is to be initialised at the beginning of a simulation run. This function is stored in the species trait dict and accessed during model setup.