Defining new species
Code structure of the animal submodel
An important aim of Persefone is to make adding new species as easy as possible, while simultaneously allowing full flexibility for modelling differences in behaviour and life cycles. To achieve this, the animal submodel works with three separate layers of code, representing different degrees of generality.
The bottom-most layer consists of functions that are needed by all species (e.g. initialisation) or that can potentially be used by many different species (e.g. movement). These are contained in the files src/animal/individuals.jl and src/animal/populations.jl. Many of these have macros available for convenient use (defined in src/animal/macros.jl).
The middle layer is the taxon layer. Taxon in this case is not a strictly scientific term in the sense of a phylogenetic clade, but rather refers to a group of species that have similar life cycles and behavioural patterns and can thus be modelled using largely the same code. At the moment, the two implemented taxa are farmland birds and grassland butterflies (in src/animal/birds.jl and src/animal/butterflies.jl). For each taxon, the animals' behaviour during different parts of the life cycle is implemented using the @phase macro (see below for details). A phase represents a period of the individual's life that is marked by specific behavioural patterns (e.g. as a larva, or when nesting).
The third and highest level of the animal submodel is the species layer. While behavioural rules are best represented using code, many other aspects of a species' ecology can be expressed as parameters (e.g. date of return from migration, or mean number of eggs per nest). It is assumed that species belonging to the same taxon (as defined above) will share the same behavioural rules, but differ in the parameter values that modify these rules. Following the general programming rule that data should be separated from logic, these species-specific parameters are therefore read in from TOML configuration files, stored in the data/animals directory. The animal.targetspecies configuration parameter selects which of these files to load during a model run.
Taking all this together, we see that adding a new species can be very simple, if it is a member of an already implemented taxon. In this case, one only needs to duplicate one of the existing species parameter files and change the parameter values to suit the new species. At the same, if the species shows behaviour that is not yet implemented in any taxon, one has the flexibility of either expanding the code for an existing taxon (if there is one that is similar enough), or creating a new taxon tailored to the life cycle of the new species one wants to simulate.
Behavioural logic is implemented on a taxon level using the @phase macro. Parameter values modifying this behaviour are defined for each species using TOML files stored in the data/animals directory.
The @phase macro
Let's have a look at this on a more technical level. Here is an example of a hypothetical mermaid taxon:
@create Mermaid begin
self.daysofdrought = 0
@debug "Created $(animalid(self))."
end
@phase Mermaid life begin
@debug "$(animalid(self)) is swimming happily in a $(@landcover())."
@respond pesticide @kill(self.pesticidemortality, "poisoning")
if @precipitation() == 0
self.daysofdrought += 1
if self.daysofdrought > self.droughttolerance
@setphase("drought")
end
elseif self.daysofdrought > 0
self.daysofdrought -= 1
end
if self.sex == female && @landcover() == WATER && chance(0.01)
@reproduce()
end
end
@phase Mermaid drought begin
@debug "$(animalid(self)) is experiencing drought."
if @precipitation() > 0
self.daysofdrought -= 1
@setphase("life")
end
end
requireparams!(:taxon, "Mermaid", [:pesticidemortality, :droughttolerance])
addspeciesparam!(:pesticidemortality, "Chance of a Mermaid dying from pesticide exposure", Float64)
addspeciesparam!(:droughttolerance, "Days without rain before a Mermaid goes into drought phase", Int)As stated above, the most important thing here is that each taxon must implement one or more @phase blocks, defining its behaviour during a certain phase of life. Under the hood, @phase creates a function that will be called daily, so long as the individual's phase variable is set to this phase. Code within this function has access to the model object (which holds all runtime data) as well as a self object, which is the currently active Animal agent. Using self, the code can access any parameter values defined in the species files. 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 life history and movement functions (see src/nature/macros.jl).
A special phase is defined using the @create macro. This is purely a utility wrapper - @create Mermaid is equivalent to @phase Mermaid create. This phase is called when initialising an individual at the start of a model run or when it is born, and can be used to instantiate life-time variables (like self.daysofdrought in the example above).
The simplest way to understand how this all works is probably to read the source code of the already implemented taxa. If you have questions, ask the Persefone developers for help.
Species parameters
So that the model can check at start-up whether all species files include all needed parameters, the animal submodel provides an internal documentation mechanism. This is demonstrated in the final lines of the example above. Each taxon should use the addspeciesparam!() and requirespeciesparams!() functions to record which parameters it uses, and under which conditions these are required (as some parameters are only needed if another parameter has a certain value). These functions can also be used to specify lists of valid values, and conversion functions for converting types that cannot be natively parsed by TOML.
Here is the list of parameters that is always required for all species:
| Parameter | Description |
|---|---|
species | The official (Latin) name of the species. |
species_en | The English name of this species. |
taxon | The taxon that this species belongs to. |
migrate | Whether this species migrates in winter or not. |
birthphase | The phase assigned to individuals at birth. |
initialphase | The phase assigned to individuals at model initialisation. |
initialiseas | The sex assigned to individuals at model initialisation. |
initialdensity | The habitat area per individual at initialisation (in ha). |
initialhabitat | A HabitatExpression describing where this species can live. |
Note that if migrate is true, several other parameters are also required, namely migrationdeparture, migrationarrival, migrationdelayfemales, and migrationmortality.
The function checkspeciesdict() can be used to check the consistency and completeness of a species parameter file. This will flag any wrong types or values, and output a list of parameters that ought to be defined but aren't (based on the given parameter values).
Habitat descriptors
A final feature of the animal submodel that still needs to be explained are the habitat descriptors. This is a string format that can be used to define which types of habitat are usable by species in different contexts. For example, the initialhabitat parameter in the table above expects such a descriptor for use during model initialisation.
Habitat descriptors are created using parsehabitat() and are stored internally as a HabitatExpression. As a short form, they can be created using the string macro @h_str, simply by prepending a habitat descriptor string with an h.
Here are some examples to demonstrate the salient properties:
parsehabitat("Fallow")
parsehabitat("Arable OR Grassland")
parsehabitat("Grassland OR (GRAIN AND NATURAL)")
h"WinterWheat AND height>5"
h"ExtensiveGrassland AND (height<=50 AND height>20)"
h"GRAIN AND (!SilageMaize AND !CornMaize)"To explain: habitat descriptors consist of one or more expressions. Expressions are linked using the boolean operators AND or OR, with parentheses used to specify priority order. Each expression is either a single word, in which case it must be a LandCover, LandCoverCategory, CropName, or CropGroup. If it is a single word, a prepended ! excludes the given value. Alternately, expressions can take the form <aspect><comparator><value>, where <aspect> is either height or cover (i.e. plant height / crop cover), and comparator is one of ==, !=, >, <, >=, <=.
While such habitat descriptors can of course also be implemented in code (using, for example, the @landcover() or @cropcover() macros), having them as strings gives the advantage that they can be included in species parameter files, and makes them easier to read.